Skip to content

Commit 0117ae8

Browse files
authored
Merge pull request #1071 from objectstack-ai/claude/refactor-llm-provider-detection
Addressing PR comments
2 parents 2296e19 + 872ab54 commit 0117ae8

File tree

17 files changed

+456
-61
lines changed

17 files changed

+456
-61
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **AIServicePlugin Auto-Detection** — AIServicePlugin now automatically detects and initializes
12+
LLM providers based on environment variables, eliminating the need for manual adapter configuration
13+
in each deployment:
14+
- Auto-detection priority: `AI_GATEWAY_MODEL``OPENAI_API_KEY``ANTHROPIC_API_KEY``GOOGLE_GENERATIVE_AI_API_KEY`
15+
- Graceful fallback to MemoryLLMAdapter when no provider is configured
16+
- Comprehensive logging of selected provider and warnings for missing SDKs
17+
- Supports custom model selection via `AI_MODEL` environment variable
18+
- Consistent behavior across CLI, Vercel, Docker, and custom deployments
19+
- Dynamic import failures are handled as soft errors with automatic fallback
20+
([#1067](https://github.com/objectstack-ai/framework/issues/1067))
21+
1122
- **Metadata Versioning & History** — Comprehensive version history tracking and rollback capabilities
1223
for metadata items. Key features include:
1324
- `MetadataHistoryRecordSchema` defining structure for historical snapshots

apps/studio/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
"preview": "vite preview"
1818
},
1919
"dependencies": {
20+
"@ai-sdk/anthropic": "^3.0.0",
21+
"@ai-sdk/gateway": "^3.0.0",
22+
"@ai-sdk/google": "^3.0.0",
23+
"@ai-sdk/openai": "^3.0.0",
2024
"@ai-sdk/react": "^3.0.144",
2125
"@hono/node-server": "^1.19.11",
2226
"@objectstack/client": "workspace:*",

apps/studio/scripts/build-vercel.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ if [ -d "../../node_modules/@libsql" ]; then
6161
else
6262
echo "[build-vercel] ⚠ @libsql not found (skipped)"
6363
fi
64+
# Copy the @ai-sdk scope (dynamically loaded provider packages)
65+
if [ -d "../../node_modules/@ai-sdk" ]; then
66+
mkdir -p "node_modules/@ai-sdk"
67+
for pkg in ../../node_modules/@ai-sdk/*/; do
68+
pkgname="$(basename "$pkg")"
69+
cp -rL "$pkg" "node_modules/@ai-sdk/$pkgname"
70+
done
71+
echo "[build-vercel] ✓ Copied @ai-sdk/*"
72+
else
73+
echo "[build-vercel] ⚠ @ai-sdk not found (skipped)"
74+
fi
6475

6576
# 4. Copy Vite build output to public/ for static file serving
6677
rm -rf public

apps/studio/scripts/bundle-api.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import { build } from 'esbuild';
1818
const EXTERNAL = [
1919
'@libsql/client',
2020
'better-sqlite3',
21+
// AI SDK provider packages — dynamically imported based on env vars
22+
'@ai-sdk/anthropic',
23+
'@ai-sdk/gateway',
24+
'@ai-sdk/google',
25+
'@ai-sdk/openai',
2126
// Optional knex database drivers — never used at runtime, but knex requires() them
2227
'pg',
2328
'pg-native',

apps/studio/vercel.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"api/**/*.js": {
1414
"memory": 1024,
1515
"maxDuration": 300,
16-
"includeFiles": "{node_modules/@libsql,node_modules/better-sqlite3}/**"
16+
"includeFiles": "{node_modules/@libsql,node_modules/better-sqlite3,node_modules/@ai-sdk}/**"
1717
}
1818
},
1919
"headers": [

packages/cli/src/commands/serve.ts

Lines changed: 5 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -344,51 +344,12 @@ export default class Serve extends Command {
344344
if (!hasAIPlugin) {
345345
try {
346346
const aiPkg = '@objectstack/service-ai';
347-
const { AIServicePlugin, VercelLLMAdapter } = await import(/* webpackIgnore: true */ aiPkg);
348-
349-
// Auto-detect LLM provider from environment variables.
350-
// Priority: 1) Vercel AI Gateway 2) Direct provider SDKs 3) MemoryLLMAdapter (echo)
351-
let adapter: any = undefined;
352-
353-
// 1. Vercel AI Gateway — works with any provider via gateway('provider/model')
354-
// Uses OIDC on Vercel, VERCEL_API_KEY locally.
355-
const gatewayModel = process.env.AI_GATEWAY_MODEL; // e.g. 'anthropic/claude-sonnet-4.6'
356-
if (gatewayModel) {
357-
try {
358-
const gatewayPkg = '@ai-sdk/gateway';
359-
const { gateway } = await import(/* webpackIgnore: true */ gatewayPkg);
360-
adapter = new VercelLLMAdapter({ model: gateway(gatewayModel) });
361-
} catch {
362-
// @ai-sdk/gateway not installed
363-
}
364-
}
365-
366-
// 2. Direct provider SDKs
367-
if (!adapter) {
368-
const providerConfigs: Array<{ envKey: string; pkg: string; factory: string; defaultModel: string }> = [
369-
{ envKey: 'OPENAI_API_KEY', pkg: '@ai-sdk/openai', factory: 'openai', defaultModel: 'gpt-4o' },
370-
{ envKey: 'ANTHROPIC_API_KEY', pkg: '@ai-sdk/anthropic', factory: 'anthropic', defaultModel: 'claude-sonnet-4-20250514' },
371-
{ envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', pkg: '@ai-sdk/google', factory: 'google', defaultModel: 'gemini-2.0-flash' },
372-
];
373-
374-
for (const { envKey, pkg, factory, defaultModel } of providerConfigs) {
375-
if (process.env[envKey]) {
376-
try {
377-
const mod = await import(/* webpackIgnore: true */ pkg);
378-
const createModel = mod[factory] ?? mod.default;
379-
if (typeof createModel === 'function') {
380-
const modelId = process.env.AI_MODEL ?? defaultModel;
381-
adapter = new VercelLLMAdapter({ model: createModel(modelId) });
382-
break;
383-
}
384-
} catch {
385-
// Provider SDK not installed — skip
386-
}
387-
}
388-
}
389-
}
347+
const { AIServicePlugin } = await import(/* webpackIgnore: true */ aiPkg);
390348

391-
await kernel.use(new AIServicePlugin(adapter ? { adapter } : undefined));
349+
// AIServicePlugin will auto-detect LLM provider from environment variables
350+
// (AI_GATEWAY_MODEL, OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY)
351+
// No need to manually construct the adapter here.
352+
await kernel.use(new AIServicePlugin());
392353
trackPlugin('AIService');
393354
} catch {
394355
// @objectstack/service-ai not installed — AI features unavailable

packages/metadata/src/loaders/database-loader.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,27 @@ export class DatabaseLoader implements MetadataLoader {
590590
);
591591
}
592592
}
593+
594+
/**
595+
* Delete a metadata item from the database
596+
*/
597+
async delete(type: string, name: string): Promise<void> {
598+
await this.ensureSchema();
599+
600+
// Find the existing record to get its ID
601+
const existing = await this.driver.findOne(this.tableName, {
602+
object: this.tableName,
603+
where: this.baseFilter(type, name),
604+
});
605+
606+
if (!existing) {
607+
// Item doesn't exist, nothing to delete
608+
return;
609+
}
610+
611+
// Delete from the main metadata table using the record's ID
612+
await this.driver.delete(this.tableName, existing.id as string);
613+
}
593614
}
594615

595616
/**

packages/metadata/src/loaders/memory-loader.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export class MemoryLoader implements MetadataLoader {
9191
if (!this.storage.has(type)) {
9292
this.storage.set(type, new Map());
9393
}
94-
94+
9595
this.storage.get(type)!.set(name, data);
9696

9797
return {
@@ -100,4 +100,17 @@ export class MemoryLoader implements MetadataLoader {
100100
saveTime: 0,
101101
};
102102
}
103+
104+
/**
105+
* Delete a metadata item from memory storage
106+
*/
107+
async delete(type: string, name: string): Promise<void> {
108+
const typeStore = this.storage.get(type);
109+
if (typeStore) {
110+
typeStore.delete(name);
111+
if (typeStore.size === 0) {
112+
this.storage.delete(type);
113+
}
114+
}
115+
}
103116
}

packages/metadata/src/metadata-history.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
import { describe, it, expect, beforeEach } from 'vitest';
44
import { MetadataManager } from './metadata-manager';
55
import { DatabaseLoader } from './loaders/database-loader';
6-
import { MemoryDriver } from '@objectstack/driver-memory';
6+
import { InMemoryDriver } from '@objectstack/driver-memory';
77

88
describe('Metadata History', () => {
99
let manager: MetadataManager;
10-
let driver: MemoryDriver;
10+
let driver: InMemoryDriver;
1111

1212
beforeEach(async () => {
1313
// Create a fresh in-memory driver and database loader
14-
driver = new MemoryDriver({});
14+
driver = new InMemoryDriver({});
1515

1616
const dbLoader = new DatabaseLoader({
1717
driver,

packages/metadata/src/metadata-manager.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,20 @@ export class MetadataManager implements IMetadataService {
153153

154154
/**
155155
* Register/save a metadata item by type
156+
* Stores in-memory registry and persists to writable loaders (if configured)
156157
*/
157158
async register(type: string, name: string, data: unknown): Promise<void> {
158159
if (!this.registry.has(type)) {
159160
this.registry.set(type, new Map());
160161
}
161162
this.registry.get(type)!.set(name, data);
163+
164+
// Persist to writable loaders (e.g., DatabaseLoader for history tracking)
165+
for (const loader of this.loaders.values()) {
166+
if (loader.save) {
167+
await loader.save(type, name, data);
168+
}
169+
}
162170
}
163171

164172
/**
@@ -213,13 +221,26 @@ export class MetadataManager implements IMetadataService {
213221
* Unregister/remove a metadata item by type and name
214222
*/
215223
async unregister(type: string, name: string): Promise<void> {
224+
// Remove from in-memory registry
216225
const typeStore = this.registry.get(type);
217226
if (typeStore) {
218227
typeStore.delete(name);
219228
if (typeStore.size === 0) {
220229
this.registry.delete(type);
221230
}
222231
}
232+
233+
// Also delete from all loaders that support deletion
234+
for (const loader of this.loaders.values()) {
235+
// Check if the loader has a delete method
236+
if (typeof (loader as any).delete === 'function') {
237+
try {
238+
await (loader as any).delete(type, name);
239+
} catch (error) {
240+
this.logger.warn(`Failed to delete ${type}/${name} from loader ${loader.contract.name}`, { error });
241+
}
242+
}
243+
}
223244
}
224245

225246
/**
@@ -321,20 +342,21 @@ export class MetadataManager implements IMetadataService {
321342
* Unregister all metadata items from a specific package
322343
*/
323344
async unregisterPackage(packageName: string): Promise<void> {
345+
// Collect all items to delete (type and name pairs)
346+
const itemsToDelete: Array<{ type: string; name: string }> = [];
347+
324348
for (const [type, typeStore] of this.registry) {
325-
const toDelete: string[] = [];
326349
for (const [name, data] of typeStore) {
327350
const meta = data as any;
328351
if (meta?.packageId === packageName || meta?.package === packageName) {
329-
toDelete.push(name);
352+
itemsToDelete.push({ type, name });
330353
}
331354
}
332-
for (const name of toDelete) {
333-
typeStore.delete(name);
334-
}
335-
if (typeStore.size === 0) {
336-
this.registry.delete(type);
337-
}
355+
}
356+
357+
// Delete each item using unregister() to ensure deletion from both registry and loaders
358+
for (const { type, name } of itemsToDelete) {
359+
await this.unregister(type, name);
338360
}
339361
}
340362

@@ -1343,6 +1365,12 @@ export class MetadataManager implements IMetadataService {
13431365
options?.recordedBy
13441366
);
13451367

1368+
// Update in-memory registry with the restored metadata
1369+
if (!this.registry.has(type)) {
1370+
this.registry.set(type, new Map());
1371+
}
1372+
this.registry.get(type)!.set(name, restoredMetadata);
1373+
13461374
return restoredMetadata;
13471375
}
13481376

0 commit comments

Comments
 (0)