Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **AIServicePlugin Auto-Detection** — AIServicePlugin now automatically detects and initializes
LLM providers based on environment variables, eliminating the need for manual adapter configuration
in each deployment:
- Auto-detection priority: `AI_GATEWAY_MODEL` → `OPENAI_API_KEY` → `ANTHROPIC_API_KEY` → `GOOGLE_GENERATIVE_AI_API_KEY`
- Graceful fallback to MemoryLLMAdapter when no provider is configured
- Comprehensive logging of selected provider and warnings for missing SDKs
- Supports custom model selection via `AI_MODEL` environment variable
- Consistent behavior across CLI, Vercel, Docker, and custom deployments
- Dynamic import failures are handled as soft errors with automatic fallback
([#1067](https://github.com/objectstack-ai/framework/issues/1067))

- **Metadata Versioning & History** — Comprehensive version history tracking and rollback capabilities
for metadata items. Key features include:
- `MetadataHistoryRecordSchema` defining structure for historical snapshots
Expand Down
4 changes: 4 additions & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"preview": "vite preview"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "^3.0.0",
"@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.144",
"@hono/node-server": "^1.19.11",
"@objectstack/client": "workspace:*",
Expand Down
11 changes: 11 additions & 0 deletions apps/studio/scripts/build-vercel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ if [ -d "../../node_modules/@libsql" ]; then
else
echo "[build-vercel] ⚠ @libsql not found (skipped)"
fi
# Copy the @ai-sdk scope (dynamically loaded provider packages)
if [ -d "../../node_modules/@ai-sdk" ]; then
mkdir -p "node_modules/@ai-sdk"
for pkg in ../../node_modules/@ai-sdk/*/; do
pkgname="$(basename "$pkg")"
cp -rL "$pkg" "node_modules/@ai-sdk/$pkgname"
done
echo "[build-vercel] ✓ Copied @ai-sdk/*"
else
echo "[build-vercel] ⚠ @ai-sdk not found (skipped)"
fi

# 4. Copy Vite build output to public/ for static file serving
rm -rf public
Expand Down
5 changes: 5 additions & 0 deletions apps/studio/scripts/bundle-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import { build } from 'esbuild';
const EXTERNAL = [
'@libsql/client',
'better-sqlite3',
// AI SDK provider packages — dynamically imported based on env vars
'@ai-sdk/anthropic',
'@ai-sdk/gateway',
'@ai-sdk/google',
'@ai-sdk/openai',
// Optional knex database drivers — never used at runtime, but knex requires() them
'pg',
'pg-native',
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"api/**/*.js": {
"memory": 1024,
"maxDuration": 300,
"includeFiles": "{node_modules/@libsql,node_modules/better-sqlite3}/**"
"includeFiles": "{node_modules/@libsql,node_modules/better-sqlite3,node_modules/@ai-sdk}/**"
}
},
"headers": [
Expand Down
49 changes: 5 additions & 44 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,51 +344,12 @@ export default class Serve extends Command {
if (!hasAIPlugin) {
try {
const aiPkg = '@objectstack/service-ai';
const { AIServicePlugin, VercelLLMAdapter } = await import(/* webpackIgnore: true */ aiPkg);

// Auto-detect LLM provider from environment variables.
// Priority: 1) Vercel AI Gateway 2) Direct provider SDKs 3) MemoryLLMAdapter (echo)
let adapter: any = undefined;

// 1. Vercel AI Gateway — works with any provider via gateway('provider/model')
// Uses OIDC on Vercel, VERCEL_API_KEY locally.
const gatewayModel = process.env.AI_GATEWAY_MODEL; // e.g. 'anthropic/claude-sonnet-4.6'
if (gatewayModel) {
try {
const gatewayPkg = '@ai-sdk/gateway';
const { gateway } = await import(/* webpackIgnore: true */ gatewayPkg);
adapter = new VercelLLMAdapter({ model: gateway(gatewayModel) });
} catch {
// @ai-sdk/gateway not installed
}
}

// 2. Direct provider SDKs
if (!adapter) {
const providerConfigs: Array<{ envKey: string; pkg: string; factory: string; defaultModel: string }> = [
{ envKey: 'OPENAI_API_KEY', pkg: '@ai-sdk/openai', factory: 'openai', defaultModel: 'gpt-4o' },
{ envKey: 'ANTHROPIC_API_KEY', pkg: '@ai-sdk/anthropic', factory: 'anthropic', defaultModel: 'claude-sonnet-4-20250514' },
{ envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', pkg: '@ai-sdk/google', factory: 'google', defaultModel: 'gemini-2.0-flash' },
];

for (const { envKey, pkg, factory, defaultModel } of providerConfigs) {
if (process.env[envKey]) {
try {
const mod = await import(/* webpackIgnore: true */ pkg);
const createModel = mod[factory] ?? mod.default;
if (typeof createModel === 'function') {
const modelId = process.env.AI_MODEL ?? defaultModel;
adapter = new VercelLLMAdapter({ model: createModel(modelId) });
break;
}
} catch {
// Provider SDK not installed — skip
}
}
}
}
const { AIServicePlugin } = await import(/* webpackIgnore: true */ aiPkg);

await kernel.use(new AIServicePlugin(adapter ? { adapter } : undefined));
// AIServicePlugin will auto-detect LLM provider from environment variables
// (AI_GATEWAY_MODEL, OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY)
// No need to manually construct the adapter here.
await kernel.use(new AIServicePlugin());
trackPlugin('AIService');
} catch {
// @objectstack/service-ai not installed — AI features unavailable
Expand Down
21 changes: 21 additions & 0 deletions packages/metadata/src/loaders/database-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,27 @@ export class DatabaseLoader implements MetadataLoader {
);
}
}

/**
* Delete a metadata item from the database
*/
async delete(type: string, name: string): Promise<void> {
await this.ensureSchema();

// Find the existing record to get its ID
const existing = await this.driver.findOne(this.tableName, {
object: this.tableName,
where: this.baseFilter(type, name),
});

if (!existing) {
// Item doesn't exist, nothing to delete
return;
}

// Delete from the main metadata table using the record's ID
await this.driver.delete(this.tableName, existing.id as string);
}
}

/**
Expand Down
15 changes: 14 additions & 1 deletion packages/metadata/src/loaders/memory-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export class MemoryLoader implements MetadataLoader {
if (!this.storage.has(type)) {
this.storage.set(type, new Map());
}

this.storage.get(type)!.set(name, data);

return {
Expand All @@ -100,4 +100,17 @@ export class MemoryLoader implements MetadataLoader {
saveTime: 0,
};
}

/**
* Delete a metadata item from memory storage
*/
async delete(type: string, name: string): Promise<void> {
const typeStore = this.storage.get(type);
if (typeStore) {
typeStore.delete(name);
if (typeStore.size === 0) {
this.storage.delete(type);
}
}
}
}
6 changes: 3 additions & 3 deletions packages/metadata/src/metadata-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { MetadataManager } from './metadata-manager';
import { DatabaseLoader } from './loaders/database-loader';
import { MemoryDriver } from '@objectstack/driver-memory';
import { InMemoryDriver } from '@objectstack/driver-memory';

describe('Metadata History', () => {
let manager: MetadataManager;
let driver: MemoryDriver;
let driver: InMemoryDriver;

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

const dbLoader = new DatabaseLoader({
driver,
Expand Down
44 changes: 36 additions & 8 deletions packages/metadata/src/metadata-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,20 @@ export class MetadataManager implements IMetadataService {

/**
* Register/save a metadata item by type
* Stores in-memory registry and persists to writable loaders (if configured)
*/
async register(type: string, name: string, data: unknown): Promise<void> {
if (!this.registry.has(type)) {
this.registry.set(type, new Map());
}
this.registry.get(type)!.set(name, data);

// Persist to writable loaders (e.g., DatabaseLoader for history tracking)
for (const loader of this.loaders.values()) {
if (loader.save) {
await loader.save(type, name, data);
}
}
}

/**
Expand Down Expand Up @@ -213,13 +221,26 @@ export class MetadataManager implements IMetadataService {
* Unregister/remove a metadata item by type and name
*/
async unregister(type: string, name: string): Promise<void> {
// Remove from in-memory registry
const typeStore = this.registry.get(type);
if (typeStore) {
typeStore.delete(name);
if (typeStore.size === 0) {
this.registry.delete(type);
}
}

// Also delete from all loaders that support deletion
for (const loader of this.loaders.values()) {
// Check if the loader has a delete method
if (typeof (loader as any).delete === 'function') {
try {
await (loader as any).delete(type, name);
} catch (error) {
this.logger.warn(`Failed to delete ${type}/${name} from loader ${loader.contract.name}`, { error });
}
}
}
}

/**
Expand Down Expand Up @@ -321,20 +342,21 @@ export class MetadataManager implements IMetadataService {
* Unregister all metadata items from a specific package
*/
async unregisterPackage(packageName: string): Promise<void> {
// Collect all items to delete (type and name pairs)
const itemsToDelete: Array<{ type: string; name: string }> = [];

for (const [type, typeStore] of this.registry) {
const toDelete: string[] = [];
for (const [name, data] of typeStore) {
const meta = data as any;
if (meta?.packageId === packageName || meta?.package === packageName) {
toDelete.push(name);
itemsToDelete.push({ type, name });
}
}
for (const name of toDelete) {
typeStore.delete(name);
}
if (typeStore.size === 0) {
this.registry.delete(type);
}
}

// Delete each item using unregister() to ensure deletion from both registry and loaders
for (const { type, name } of itemsToDelete) {
await this.unregister(type, name);
}
}

Expand Down Expand Up @@ -1343,6 +1365,12 @@ export class MetadataManager implements IMetadataService {
options?.recordedBy
);

// Update in-memory registry with the restored metadata
if (!this.registry.has(type)) {
this.registry.set(type, new Map());
}
this.registry.get(type)!.set(name, restoredMetadata);

return restoredMetadata;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/metadata/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export default defineConfig({
resolve: {
alias: {
'@objectstack/core': path.resolve(__dirname, '../core/src/index.ts'),
'@objectstack/driver-memory': path.resolve(__dirname, '../plugins/driver-memory/src/index.ts'),
'@objectstack/spec/api': path.resolve(__dirname, '../spec/src/api/index.ts'),
'@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'),
'@objectstack/spec/data': path.resolve(__dirname, '../spec/src/data/index.ts'),
'@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'),
Expand Down
11 changes: 8 additions & 3 deletions packages/runtime/src/app-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,15 @@ describe('AppPlugin', () => {
objects: []
};
const plugin = new AppPlugin(bundle);


// Mock the manifest service
const mockManifestService = { register: vi.fn() };
vi.mocked(mockContext.getService).mockReturnValue(mockManifestService);

await plugin.init(mockContext);

expect(mockContext.registerService).toHaveBeenCalledWith('app.com.test.simple', bundle);

expect(mockContext.getService).toHaveBeenCalledWith('manifest');
expect(mockManifestService.register).toHaveBeenCalledWith(bundle);
});

it('start should do nothing if no runtime hooks', async () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/runtime/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { defineConfig } from 'vitest/config';
import path from 'node:path';

export default defineConfig({
resolve: {
alias: {
'@objectstack/core': path.resolve(__dirname, '../core/src/index.ts'),
'@objectstack/rest': path.resolve(__dirname, '../rest/src/index.ts'),
'@objectstack/spec/api': path.resolve(__dirname, '../spec/src/api/index.ts'),
'@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'),
'@objectstack/spec/data': path.resolve(__dirname, '../spec/src/data/index.ts'),
'@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'),
'@objectstack/spec/system': path.resolve(__dirname, '../spec/src/system/index.ts'),
'@objectstack/spec': path.resolve(__dirname, '../spec/src/index.ts'),
'@objectstack/types': path.resolve(__dirname, '../types/src/index.ts'),
},
},
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});
20 changes: 20 additions & 0 deletions packages/services/service-ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@
"@objectstack/spec": "workspace:*",
"ai": "^6.0.0"
},
"peerDependencies": {
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "^3.0.0",
"@ai-sdk/openai": "^3.0.0"
},
"peerDependenciesMeta": {
"@ai-sdk/anthropic": {
"optional": true
},
"@ai-sdk/gateway": {
"optional": true
},
"@ai-sdk/google": {
"optional": true
},
"@ai-sdk/openai": {
"optional": true
}
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^6.0.2",
Expand Down
Loading
Loading