feat: add xAI Grok adapters
This commit is contained in:
@@ -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`
|
||||
|
||||
274
src/grok.ts
Normal file
274
src/grok.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type {
|
||||
ImageGenerationRequest,
|
||||
ImageModelAdapter,
|
||||
StructuredGenerationRequest,
|
||||
StructuredModelAdapter,
|
||||
TextGenerationRequest,
|
||||
TextModelAdapter,
|
||||
} from './adapters';
|
||||
|
||||
type GrokFetch = typeof fetch;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export interface GrokAdapterOptions {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
baseUrl?: string | undefined;
|
||||
fetch?: GrokFetch | undefined;
|
||||
extraHeaders?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
export interface GrokAdapterBundleOptions {
|
||||
apiKey: string;
|
||||
textModel: string;
|
||||
structuredModel: string;
|
||||
imageModel: string;
|
||||
baseUrl?: string | undefined;
|
||||
fetch?: GrokFetch | undefined;
|
||||
extraHeaders?: Record<string, string> | 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<string> {
|
||||
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<TSchema = unknown>(options: GrokAdapterOptions): StructuredModelAdapter<TSchema> {
|
||||
const runtime = createRuntime(options);
|
||||
|
||||
return {
|
||||
provider: GROK_PROVIDER,
|
||||
model: options.model,
|
||||
async generateObject<TOutput>(request: StructuredGenerationRequest<TSchema>): Promise<TOutput> {
|
||||
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<TOutput>(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<GrokAdapterOptions, 'apiKey' | 'baseUrl' | 'fetch' | 'extraHeaders'>): {
|
||||
postJson: (path: string, body: JsonObject) => Promise<JsonObject>;
|
||||
} {
|
||||
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<TOutput>(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}`);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
147
tests/grok.test.ts
Normal file
147
tests/grok.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user