diff --git a/.changeset/ai-sdk-storage-seam.md b/.changeset/ai-sdk-storage-seam.md new file mode 100644 index 0000000..d591963 --- /dev/null +++ b/.changeset/ai-sdk-storage-seam.md @@ -0,0 +1,9 @@ +--- +"@gemstack/ai-sdk": minor +--- + +Decouple `ImageGenerator.store()` / `AudioGenerator.store()` from `@rudderjs/storage` (epic: framework-agnostic engine). + +Both `.store()` helpers no longer lazy-import `@rudderjs/storage`. They now take a required, caller-supplied storage via a new exported `StorageAdapter` contract (a one-method interface: `put(path, bytes)`). Implement it against any blob store (S3, GCS, the filesystem, a framework's storage layer). + +**Breaking (0.x):** `.store(path)` is now `.store(path, storage)`. Migrate `await ImageGenerator.of(p).store('out.png')` to `await ImageGenerator.of(p).store('out.png', storage)` where `storage` satisfies `StorageAdapter`. A Rudder app wraps `@rudderjs/storage` in a ~3-line adapter. diff --git a/packages/ai-sdk/src/audio.ts b/packages/ai-sdk/src/audio.ts index d411a68..b4c94b1 100644 --- a/packages/ai-sdk/src/audio.ts +++ b/packages/ai-sdk/src/audio.ts @@ -1,4 +1,5 @@ import { AiRegistry, tryWithFailover } from './registry.js' +import type { StorageAdapter } from './storage-adapter.js' import type { TextToSpeechResult } from './types.js' type AudioFormat = 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' @@ -8,7 +9,7 @@ type AudioFormat = 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' * * @example * const result = await AudioGenerator.of('Hello world').voice('alloy').generate() - * await AudioGenerator.of('Hello').format('wav').store('audio/greeting.wav') + * await AudioGenerator.of('Hello').format('wav').store('audio/greeting.wav', storage) * * @example Failover across providers * const result = await AudioGenerator.of('Hello') @@ -88,21 +89,22 @@ export class AudioGenerator { }) } - /** Generate audio and store it via @rudderjs/storage */ - async store(path: string): Promise { - const result = await this.generate() - - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const mod = await import(/* @vite-ignore */ '@rudderjs/storage' as string) - const Storage = mod.Storage - await Storage.disk().put(path, result.audio) - return path - } catch { - throw new Error( - '[ai-sdk] @rudderjs/storage is required for AudioGenerator.store(). ' + - 'Install it: pnpm add @rudderjs/storage', - ) + /** + * Generate audio and persist it through a caller-supplied + * {@link StorageAdapter}. Returns the `path` it was stored at. + * + * ```ts + * import { writeFile } from 'node:fs/promises' + * await AudioGenerator.of('Hello') + * .store('audio/greeting.wav', { put: (p, bytes) => writeFile(p, bytes) }) + * ``` + */ + async store(path: string, storage: StorageAdapter): Promise { + if (!storage) { + throw new Error('[ai-sdk] AudioGenerator.store(path, storage) requires a StorageAdapter.') } + const result = await this.generate() + await storage.put(path, result.audio) + return path } } diff --git a/packages/ai-sdk/src/image.ts b/packages/ai-sdk/src/image.ts index ab38e28..05076dd 100644 --- a/packages/ai-sdk/src/image.ts +++ b/packages/ai-sdk/src/image.ts @@ -1,5 +1,6 @@ import { AiRegistry, tryWithFailover } from './registry.js' import { fromBase64 } from './base64.js' +import type { StorageAdapter } from './storage-adapter.js' import type { ImageGenerationOptions, ImageGenerationResult } from './types.js' /** @@ -7,7 +8,7 @@ import type { ImageGenerationOptions, ImageGenerationResult } from './types.js' * * @example * const result = await ImageGenerator.of('A sunset over mountains').size('landscape').generate() - * const path = await ImageGenerator.of('A logo').model('openai/dall-e-3').store('images/logo.png') + * const path = await ImageGenerator.of('A logo').model('openai/dall-e-3').store('images/logo.png', storage) * * @example Failover across providers * const result = await ImageGenerator.of('A donut') @@ -89,29 +90,33 @@ export class ImageGenerator { }) } - /** Generate and store the first image to storage. Requires @rudderjs/storage. */ - async store(path: string): Promise { + /** + * Generate the first image and persist it through a caller-supplied + * {@link StorageAdapter}. Returns the `path` it was stored at. + * + * ```ts + * import { writeFile } from 'node:fs/promises' + * await ImageGenerator.of('a logo') + * .store('out/logo.png', { put: (p, bytes) => writeFile(p, bytes) }) + * ``` + */ + async store(path: string, storage: StorageAdapter): Promise { + if (!storage) { + throw new Error('[ai-sdk] ImageGenerator.store(path, storage) requires a StorageAdapter.') + } const result = await this.generate() const image = result.images[0] if (!image) throw new Error('[ai-sdk] No image generated.') - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const mod: any = await import(/* @vite-ignore */ '@rudderjs/storage' as string) - const Storage = mod.Storage - - if (image.base64) { - const bytes = fromBase64(image.base64) - await Storage.put(path, bytes) - } else if (image.url) { - const response = await fetch(image.url) - const bytes = new Uint8Array(await response.arrayBuffer()) - await Storage.put(path, bytes) - } - - return path - } catch { - throw new Error('[ai-sdk] Image storage requires @rudderjs/storage to be installed.') + if (image.base64) { + await storage.put(path, fromBase64(image.base64)) + } else if (image.url) { + const response = await fetch(image.url) + await storage.put(path, new Uint8Array(await response.arrayBuffer())) + } else { + throw new Error('[ai-sdk] Generated image has neither base64 data nor a url to store.') } + + return path } } diff --git a/packages/ai-sdk/src/index.test.ts b/packages/ai-sdk/src/index.test.ts index cf75170..c21e0b7 100644 --- a/packages/ai-sdk/src/index.test.ts +++ b/packages/ai-sdk/src/index.test.ts @@ -1656,6 +1656,47 @@ describe('Media failover', () => { assert.equal(primaryCalls, 1) assert.equal(fallbackCalls, 0) }) + + it('ImageGenerator.store() writes base64 bytes through a StorageAdapter and returns the path', async () => { + AiRegistry.reset() + const img: import('./types.js').ImageGenerationAdapter = { + // 'OK' base64-decodes to bytes [0x39, 0x0a] + async generate(opts) { return { images: [{ base64: 'OK' }], model: opts.model ?? 'm' } }, + } + AiRegistry.register({ name: 'img', create: (m) => mockFactory.create(m), createImage: () => img }) + + const writes: Array<{ path: string; bytes: Uint8Array }> = [] + const storage = { put(path: string, bytes: Uint8Array) { writes.push({ path, bytes }) } } + + const path = await ImageGenerator.of('x').model('img/v1').store('out/logo.png', storage) + assert.equal(path, 'out/logo.png') + assert.equal(writes.length, 1) + assert.equal(writes[0]!.path, 'out/logo.png') + assert.ok(writes[0]!.bytes instanceof Uint8Array) + }) + + it('AudioGenerator.store() writes the audio bytes through a StorageAdapter', async () => { + AiRegistry.reset() + const tts: import('./types.js').TextToSpeechAdapter = { + async generate(opts) { return { audio: Buffer.from('audio-bytes'), format: opts.format ?? 'mp3', model: opts.model ?? 'm' } }, + } + AiRegistry.register({ name: 'tts', create: (m) => mockFactory.create(m), createTts: () => tts }) + + let stored: { path: string; bytes: Uint8Array } | undefined + const storage = { async put(path: string, bytes: Uint8Array) { stored = { path, bytes } } } + + const path = await AudioGenerator.of('Hi').model('tts/v1').store('audio/hi.mp3', storage) + assert.equal(path, 'audio/hi.mp3') + assert.equal(Buffer.from(stored!.bytes).toString(), 'audio-bytes') + }) + + it('store() throws without a StorageAdapter', async () => { + AiRegistry.reset() + await assert.rejects( + () => (ImageGenerator.of('x') as unknown as { store(p: string): Promise }).store('p'), + /requires a StorageAdapter/, + ) + }) }) // ─── AiFake ─────────────────────────────────────────────── diff --git a/packages/ai-sdk/src/index.ts b/packages/ai-sdk/src/index.ts index 4c0f194..46cfb36 100644 --- a/packages/ai-sdk/src/index.ts +++ b/packages/ai-sdk/src/index.ts @@ -164,6 +164,9 @@ export type { MemoryExtractOptions } from './memory-extract.js' // Neutral cache contract for the cache-backed run stores (bring your own) export type { CacheAdapter } from './cache-adapter.js' +// Neutral storage contract for ImageGenerator/AudioGenerator .store() (bring your own) +export type { StorageAdapter } from './storage-adapter.js' + // Sub-agent run store (asTool streaming + suspend) export { InMemorySubAgentRunStore, diff --git a/packages/ai-sdk/src/storage-adapter.ts b/packages/ai-sdk/src/storage-adapter.ts new file mode 100644 index 0000000..9a58860 --- /dev/null +++ b/packages/ai-sdk/src/storage-adapter.ts @@ -0,0 +1,19 @@ +/** + * Neutral storage contract for persisting generated binary assets (images, + * audio). + * + * `@gemstack/ai-sdk` does not bundle or depend on any storage implementation. + * Implement this one-method interface against whatever you store blobs in + * (S3, GCS, the local filesystem, a framework's storage layer) and pass it to + * {@link ImageGenerator.store} / {@link AudioGenerator.store}. + * + * ```ts + * import { writeFile } from 'node:fs/promises' + * const storage: StorageAdapter = { put: (path, bytes) => writeFile(path, bytes) } + * await ImageGenerator.of('a logo').store('out/logo.png', storage) + * ``` + */ +export interface StorageAdapter { + /** Persist raw bytes at a logical path/key. */ + put(path: string, bytes: Uint8Array): Promise | void +}