diff --git a/README.md b/README.md index ffc28dc..64a86f5 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ BoxBrain is an IdentityDB-backed TypeScript framework for creating synthetic per The project is framework-first rather than product-first. The current core library provides: - provider-agnostic text, structured-output, image, conversation-memory, and special-date adapter contracts +- ready-made xAI Grok text, structured-output, and image adapters - one IdentityDB memory space per persona - persona initialization from personality, history, values, preferences, and relationships - LLM-generated biography ingestion into IdentityDB fact drafts @@ -21,7 +22,7 @@ The project is framework-first rather than product-first. The current core libra Still planned: - HTTP/RPC wrappers around the core library APIs -- ready-made provider adapter packages for specific AI vendors +- ready-made provider adapter packages for additional AI vendors - production-focused persistence/runtime integrations beyond the in-process core library ## Development @@ -35,6 +36,6 @@ bun run build ## Current status -The repository now contains the framework core for persona initialization, schedule/status management, and conversation orchestration. See the implementation plan: +The repository now contains the framework core for persona initialization, schedule/status management, conversation orchestration, and a ready-made Grok adapter set. See the implementation plan: - `docs/plans/2026-05-11-boxbrain-foundation.md` diff --git a/src/grok.ts b/src/grok.ts new file mode 100644 index 0000000..7661744 --- /dev/null +++ b/src/grok.ts @@ -0,0 +1,274 @@ +import type { + ImageGenerationRequest, + ImageModelAdapter, + StructuredGenerationRequest, + StructuredModelAdapter, + TextGenerationRequest, + TextModelAdapter, +} from './adapters'; + +type GrokFetch = typeof fetch; + +type JsonObject = Record; + +export interface GrokAdapterOptions { + apiKey: string; + model: string; + baseUrl?: string | undefined; + fetch?: GrokFetch | undefined; + extraHeaders?: Record | undefined; +} + +export interface GrokAdapterBundleOptions { + apiKey: string; + textModel: string; + structuredModel: string; + imageModel: string; + baseUrl?: string | undefined; + fetch?: GrokFetch | undefined; + extraHeaders?: Record | undefined; +} + +const DEFAULT_BASE_URL = 'https://api.x.ai/v1'; +const GROK_PROVIDER = 'xai-grok'; + +export function createGrokTextModelAdapter(options: GrokAdapterOptions): TextModelAdapter { + const runtime = createRuntime(options); + + return { + provider: GROK_PROVIDER, + model: options.model, + async generateText(request: TextGenerationRequest): Promise { + const response = await runtime.postJson('/chat/completions', { + model: request.model ?? options.model, + messages: buildMessages(request.system, request.prompt), + temperature: request.temperature, + metadata: request.metadata, + }); + + return extractChatCompletionText(response); + }, + }; +} + +export function createGrokStructuredModelAdapter(options: GrokAdapterOptions): StructuredModelAdapter { + const runtime = createRuntime(options); + + return { + provider: GROK_PROVIDER, + model: options.model, + async generateObject(request: StructuredGenerationRequest): Promise { + const response = await runtime.postJson('/chat/completions', { + model: request.model ?? options.model, + messages: buildMessages(request.system, request.prompt), + temperature: request.temperature, + metadata: request.metadata, + response_format: request.schema + ? { + type: 'json_schema', + json_schema: { + name: 'boxbrain_structured_output', + schema: request.schema, + }, + } + : { type: 'json_object' }, + }); + + return parseStructuredOutput(extractChatCompletionText(response)); + }, + }; +} + +export function createGrokImageModelAdapter(options: GrokAdapterOptions): ImageModelAdapter { + const runtime = createRuntime(options); + + return { + provider: GROK_PROVIDER, + model: options.model, + async generateImage(request: ImageGenerationRequest) { + const response = await runtime.postJson('/images/generations', { + model: request.model ?? options.model, + prompt: request.prompt, + aspect_ratio: mapAspectRatio(request.aspectRatio), + metadata: request.metadata, + }); + + const image = extractFirstImage(response); + return { + url: image.url, + revisedPrompt: image.revised_prompt, + }; + }, + }; +} + +export function createGrokAdapters(options: GrokAdapterBundleOptions): { + text: TextModelAdapter; + structured: StructuredModelAdapter; + image: ImageModelAdapter; +} { + return { + text: createGrokTextModelAdapter({ + apiKey: options.apiKey, + model: options.textModel, + baseUrl: options.baseUrl, + fetch: options.fetch, + extraHeaders: options.extraHeaders, + }), + structured: createGrokStructuredModelAdapter({ + apiKey: options.apiKey, + model: options.structuredModel, + baseUrl: options.baseUrl, + fetch: options.fetch, + extraHeaders: options.extraHeaders, + }), + image: createGrokImageModelAdapter({ + apiKey: options.apiKey, + model: options.imageModel, + baseUrl: options.baseUrl, + fetch: options.fetch, + extraHeaders: options.extraHeaders, + }), + }; +} + +function createRuntime(options: Pick): { + postJson: (path: string, body: JsonObject) => Promise; +} { + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ''); + const fetchImpl = options.fetch ?? globalThis.fetch; + if (!fetchImpl) { + throw new Error('Grok adapter requires a fetch implementation.'); + } + + return { + async postJson(path, body) { + const response = await fetchImpl(`${baseUrl}${path}`, { + method: 'POST', + headers: { + authorization: `Bearer ${options.apiKey}`, + 'content-type': 'application/json', + ...(options.extraHeaders ?? {}), + }, + body: JSON.stringify(removeUndefined(body)), + }); + + const text = await response.text(); + const parsed = text.length > 0 ? tryParseJson(text) : {}; + if (!response.ok) { + throw new Error(`Grok API request failed (${response.status}): ${text}`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Grok API response must be a JSON object.'); + } + + return parsed; + }, + }; +} + +function buildMessages(system: string | undefined, prompt: string): Array<{ role: 'system' | 'user'; content: string }> { + const messages: Array<{ role: 'system' | 'user'; content: string }> = []; + if (system) { + messages.push({ role: 'system', content: system }); + } + messages.push({ role: 'user', content: prompt }); + return messages; +} + +function extractChatCompletionText(response: JsonObject): string { + const choices = response.choices; + if (!Array.isArray(choices) || choices.length === 0) { + throw new Error('Grok chat completion response did not include choices.'); + } + + const firstChoice = choices[0]; + if (!firstChoice || typeof firstChoice !== 'object' || Array.isArray(firstChoice)) { + throw new Error('Grok chat completion response choice was invalid.'); + } + + const message = (firstChoice as JsonObject).message; + if (!message || typeof message !== 'object' || Array.isArray(message)) { + throw new Error('Grok chat completion response did not include a message.'); + } + + const content = (message as JsonObject).content; + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + return content + .map((part) => { + if (!part || typeof part !== 'object' || Array.isArray(part)) { + return ''; + } + const text = (part as JsonObject).text; + return typeof text === 'string' ? text : ''; + }) + .join('') + .trim(); + } + + throw new Error('Grok chat completion response did not include text content.'); +} + +function parseStructuredOutput(text: string): TOutput { + const parsed = tryParseJson(text); + return parsed as TOutput; +} + +function extractFirstImage(response: JsonObject): { url?: string; revised_prompt?: string } { + const data = response.data; + if (!Array.isArray(data) || data.length === 0) { + throw new Error('Grok image generation response did not include image data.'); + } + + const first = data[0]; + if (!first || typeof first !== 'object' || Array.isArray(first)) { + throw new Error('Grok image generation response image entry was invalid.'); + } + + const url = (first as JsonObject).url; + const revisedPrompt = (first as JsonObject).revised_prompt; + const result: { url?: string; revised_prompt?: string } = {}; + + if (typeof url === 'string') { + result.url = url; + } + + if (typeof revisedPrompt === 'string') { + result.revised_prompt = revisedPrompt; + } + + return result; +} + +function mapAspectRatio(aspectRatio: ImageGenerationRequest['aspectRatio']): string | undefined { + if (aspectRatio === 'square') { + return '1:1'; + } + if (aspectRatio === 'portrait') { + return '3:4'; + } + if (aspectRatio === 'landscape') { + return '16:9'; + } + return undefined; +} + +function removeUndefined(value: JsonObject): JsonObject { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)); +} + +function tryParseJson(text: string): JsonObject { + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('JSON value was not an object.'); + } + return parsed as JsonObject; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse Grok JSON response: ${message}`); + } +} diff --git a/src/index.ts b/src/index.ts index 30b831e..9ca629b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from './adapters'; export * from './availability'; export * from './conversation'; +export * from './grok'; export * from './memory'; export * from './persona'; export * from './schedule'; diff --git a/tests/grok.test.ts b/tests/grok.test.ts new file mode 100644 index 0000000..24f25d5 --- /dev/null +++ b/tests/grok.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { + createGrokAdapters, + createGrokImageModelAdapter, + createGrokStructuredModelAdapter, + createGrokTextModelAdapter, +} from '../src'; + +describe('Grok adapters', () => { + it('creates a text adapter that calls xAI chat completions', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const adapter = createGrokTextModelAdapter({ + apiKey: 'test-key', + model: 'grok-4.3-mini', + fetch: async (url, init) => { + calls.push({ url: String(url), ...(init ? { init } : {}) }); + return new Response(JSON.stringify({ + choices: [ + { + message: { + content: 'hello from grok', + }, + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + await expect(adapter.generateText({ + system: 'You are a helpful persona engine.', + prompt: 'Say hi.', + temperature: 0.3, + })).resolves.toBe('hello from grok'); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe('https://api.x.ai/v1/chat/completions'); + expect(calls[0]?.init?.method).toBe('POST'); + expect(calls[0]?.init?.headers).toMatchObject({ + authorization: 'Bearer test-key', + 'content-type': 'application/json', + }); + + const body = JSON.parse(String(calls[0]?.init?.body)); + expect(body.model).toBe('grok-4.3-mini'); + expect(body.temperature).toBe(0.3); + expect(body.messages).toEqual([ + { role: 'system', content: 'You are a helpful persona engine.' }, + { role: 'user', content: 'Say hi.' }, + ]); + }); + + it('creates a structured adapter that sends json_schema response_format and parses JSON', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const adapter = createGrokStructuredModelAdapter({ + apiKey: 'test-key', + model: 'grok-4.3', + fetch: async (url, init) => { + calls.push({ url: String(url), ...(init ? { init } : {}) }); + return new Response(JSON.stringify({ + choices: [ + { + message: { + content: JSON.stringify({ biography: 'Born in Busan.' }), + }, + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + const schema = { + type: 'object', + required: ['biography'], + properties: { biography: { type: 'string' } }, + }; + + await expect(adapter.generateObject<{ biography: string }>({ + prompt: 'Write a biography.', + system: 'Return only JSON.', + schema, + })).resolves.toEqual({ biography: 'Born in Busan.' }); + + const body = JSON.parse(String(calls[0]?.init?.body)); + expect(body.response_format).toEqual({ + type: 'json_schema', + json_schema: { + name: 'boxbrain_structured_output', + schema, + }, + }); + }); + + it('creates an image adapter that calls xAI image generation with mapped aspect ratios', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const adapter = createGrokImageModelAdapter({ + apiKey: 'test-key', + model: 'grok-imagine-image-quality', + fetch: async (url, init) => { + calls.push({ url: String(url), ...(init ? { init } : {}) }); + return new Response(JSON.stringify({ + data: [{ url: 'https://cdn.x.ai/generated.png', revised_prompt: 'revised prompt' }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + await expect(adapter.generateImage({ + prompt: 'A quiet portrait photo.', + aspectRatio: 'portrait', + })).resolves.toEqual({ + url: 'https://cdn.x.ai/generated.png', + revisedPrompt: 'revised prompt', + }); + + expect(calls[0]?.url).toBe('https://api.x.ai/v1/images/generations'); + const body = JSON.parse(String(calls[0]?.init?.body)); + expect(body.aspect_ratio).toBe('3:4'); + expect(body.model).toBe('grok-imagine-image-quality'); + }); + + it('can create a bundled Grok adapter set with shared defaults', async () => { + const adapters = createGrokAdapters({ + apiKey: 'bundle-key', + textModel: 'grok-4.3-mini', + structuredModel: 'grok-4.3', + imageModel: 'grok-imagine-image-quality', + fetch: async (_url, _init) => new Response(JSON.stringify({ + choices: [{ message: { content: 'ok' } }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + }); + + expect(adapters.text.provider).toBe('xai-grok'); + expect(adapters.structured.model).toBe('grok-4.3'); + expect(adapters.image.model).toBe('grok-imagine-image-quality'); + }); +}); diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index c37dc90..4403d50 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + createGrokAdapters, createReplyDelay, createTypingDelay, generateSchedule, @@ -38,7 +39,7 @@ describe('public API', () => { expect(fact.topics.map((topic) => topic.name)).toEqual(['Mina', 'quiet cafés']); }); - it('exports schedule, conversation, and external special-date adapter contracts', () => { + it('exports schedule, conversation, Grok, and external special-date adapter contracts', () => { const specialDateProvider: SpecialDateProvider = { async listSpecialDates() { return [{ date: '2026-05-08', title: 'Parents Day' }]; @@ -47,6 +48,7 @@ describe('public API', () => { expect(typeof generateSchedule).toBe('function'); expect(typeof replyToConversation).toBe('function'); + expect(typeof createGrokAdapters).toBe('function'); expect(specialDateProvider.listSpecialDates).toBeTypeOf('function'); }); });