feat: add xAI Grok adapters

This commit is contained in:
2026-05-11 17:16:48 +09:00
parent 3ee6b233ea
commit 5be64756ac
5 changed files with 428 additions and 3 deletions

View File

@@ -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: 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 - 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 - one IdentityDB memory space per persona
- persona initialization from personality, history, values, preferences, and relationships - persona initialization from personality, history, values, preferences, and relationships
- LLM-generated biography ingestion into IdentityDB fact drafts - 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: Still planned:
- HTTP/RPC wrappers around the core library APIs - 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 - production-focused persistence/runtime integrations beyond the in-process core library
## Development ## Development
@@ -35,6 +36,6 @@ bun run build
## Current status ## 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` - `docs/plans/2026-05-11-boxbrain-foundation.md`

274
src/grok.ts Normal file
View 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}`);
}
}

View File

@@ -1,6 +1,7 @@
export * from './adapters'; export * from './adapters';
export * from './availability'; export * from './availability';
export * from './conversation'; export * from './conversation';
export * from './grok';
export * from './memory'; export * from './memory';
export * from './persona'; export * from './persona';
export * from './schedule'; export * from './schedule';

147
tests/grok.test.ts Normal file
View 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');
});
});

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
createGrokAdapters,
createReplyDelay, createReplyDelay,
createTypingDelay, createTypingDelay,
generateSchedule, generateSchedule,
@@ -38,7 +39,7 @@ describe('public API', () => {
expect(fact.topics.map((topic) => topic.name)).toEqual(['Mina', 'quiet cafés']); 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 = { const specialDateProvider: SpecialDateProvider = {
async listSpecialDates() { async listSpecialDates() {
return [{ date: '2026-05-08', title: 'Parents Day' }]; return [{ date: '2026-05-08', title: 'Parents Day' }];
@@ -47,6 +48,7 @@ describe('public API', () => {
expect(typeof generateSchedule).toBe('function'); expect(typeof generateSchedule).toBe('function');
expect(typeof replyToConversation).toBe('function'); expect(typeof replyToConversation).toBe('function');
expect(typeof createGrokAdapters).toBe('function');
expect(specialDateProvider.listSpecialDates).toBeTypeOf('function'); expect(specialDateProvider.listSpecialDates).toBeTypeOf('function');
}); });
}); });