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
9 changes: 9 additions & 0 deletions .changeset/ai-sdk-storage-seam.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 18 additions & 16 deletions packages/ai-sdk/src/audio.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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')
Expand Down Expand Up @@ -88,21 +89,22 @@ export class AudioGenerator {
})
}

/** Generate audio and store it via @rudderjs/storage */
async store(path: string): Promise<string> {
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<string> {
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
}
}
45 changes: 25 additions & 20 deletions packages/ai-sdk/src/image.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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'

/**
* Fluent image generation facade.
*
* @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')
Expand Down Expand Up @@ -89,29 +90,33 @@ export class ImageGenerator {
})
}

/** Generate and store the first image to storage. Requires @rudderjs/storage. */
async store(path: string): Promise<string> {
/**
* 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<string> {
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
}
}
41 changes: 41 additions & 0 deletions packages/ai-sdk/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> }).store('p'),
/requires a StorageAdapter/,
)
})
})

// ─── AiFake ───────────────────────────────────────────────
Expand Down
3 changes: 3 additions & 0 deletions packages/ai-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions packages/ai-sdk/src/storage-adapter.ts
Original file line number Diff line number Diff line change
@@ -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> | void
}
Loading