diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 509d644f2..c18d5e862 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -112,6 +112,7 @@ export const BaseProviders = [ 'googlemeet', 'googlesheets', 'grafana', + 'griptape', 'groqcloud', 'habitica', 'hackernews', @@ -289,6 +290,7 @@ export const ProviderDisplayNames = { googlemeet: 'Google Meet', googlesheets: 'Google Sheets', grafana: 'Grafana', + griptape: 'Griptape', groqcloud: 'GroqCloud', habitica: 'Habitica', hackernews: 'Hacker News', @@ -473,6 +475,7 @@ export type AllProviders = | 'googlemeet' | 'googlesheets' | 'grafana' + | 'griptape' | 'groqcloud' | 'habitica' | 'hackernews' diff --git a/packages/griptape/client.test.ts b/packages/griptape/client.test.ts new file mode 100644 index 000000000..43ad3852d --- /dev/null +++ b/packages/griptape/client.test.ts @@ -0,0 +1,94 @@ +import type { ApiRequestOptions, ApiResult } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; +import { GriptapeAPIError, makeGriptapeRequest } from './client'; + +jest.mock('corsair/http', () => ({ + ...jest.requireActual('corsair/http'), + request: jest.fn(), +})); + +const mockRequest = request as jest.MockedFunction; + +const sampleRequest: ApiRequestOptions = { + method: 'GET', + url: 'assistants', +}; + +function apiErrorOf( + status: number, + statusText: string, + retryAfterMs?: number, +): ApiError { + const result: ApiResult = { + url: 'https://cloud.griptape.ai/api/assistants', + ok: false, + status, + statusText, + body: { message: statusText }, + }; + return new ApiError( + sampleRequest, + result, + statusText, + retryAfterMs === undefined ? undefined : { retryAfter: retryAfterMs }, + ); +} + +describe('makeGriptapeRequest error handling', () => { + beforeEach(() => { + mockRequest.mockReset(); + }); + + it('rethrows ApiError unchanged so status-based handlers keep working', async () => { + const rateLimitError = apiErrorOf(429, 'Too Many Requests', 30000); + mockRequest.mockRejectedValueOnce(rateLimitError); + + await expect( + makeGriptapeRequest('assistants', 'test-api-key'), + ).rejects.toBe(rateLimitError); + }); + + it('keeps status and Retry-After readable on the rethrown rate-limit error', async () => { + mockRequest.mockRejectedValueOnce( + apiErrorOf(429, 'Too Many Requests', 45000), + ); + + const error = await makeGriptapeRequest('assistants', 'test-api-key').then( + () => null, + (error: unknown) => error, + ); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ + name: 'ApiError', + status: 429, + retryAfter: 45000, + }); + }); + + it('propagates authentication errors with their status code', async () => { + const authError = apiErrorOf(401, 'Unauthorized'); + mockRequest.mockRejectedValueOnce(authError); + + await expect(makeGriptapeRequest('assistants', 'invalid-key')).rejects.toBe( + authError, + ); + }); + + it('wraps non-API network failures as GriptapeAPIError', async () => { + mockRequest.mockRejectedValueOnce(new Error('socket hang up')); + + const rejection = makeGriptapeRequest('assistants', 'test-api-key'); + + await expect(rejection).rejects.toBeInstanceOf(GriptapeAPIError); + await expect(rejection).rejects.toThrow('socket hang up'); + }); + + it('maps non-Error rejections to GriptapeAPIError with a generic message', async () => { + mockRequest.mockRejectedValueOnce('boom'); + + await expect( + makeGriptapeRequest('assistants', 'test-api-key'), + ).rejects.toEqual(new GriptapeAPIError('Unknown error')); + }); +}); diff --git a/packages/griptape/client.ts b/packages/griptape/client.ts new file mode 100644 index 000000000..13bf3a342 --- /dev/null +++ b/packages/griptape/client.ts @@ -0,0 +1,64 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export class GriptapeAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'GriptapeAPIError'; + } +} + +const GRIPTAPE_API_BASE = 'https://cloud.griptape.ai/api'; + +export async function makeGriptapeRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: Record; + } = {}, +): Promise { + const { method = 'GET', body, query } = options; + + const config: OpenAPIConfig = { + BASE: GRIPTAPE_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: apiKey, + HEADERS: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: + method === 'POST' || method === 'PUT' || method === 'PATCH' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query: method === 'GET' ? query : undefined, + }; + + try { + return await request(config, requestOptions); + } catch (error) { + // Re-thrown as-is: ApiError already carries the HTTP status code and + // Retry-After info that error-handlers.ts inspects. Wrapping it here + // would hide those fields behind a message string. + if (error instanceof ApiError) { + throw error; + } + if (error instanceof Error) { + throw new GriptapeAPIError(error.message); + } + throw new GriptapeAPIError('Unknown error'); + } +} diff --git a/packages/griptape/endpoints.test.ts b/packages/griptape/endpoints.test.ts new file mode 100644 index 000000000..270e0a8e4 --- /dev/null +++ b/packages/griptape/endpoints.test.ts @@ -0,0 +1,253 @@ +import { logEventFromContext } from 'corsair/core'; +import { ApiError, request } from 'corsair/http'; +import type { + AssistantGetResponse, + AssistantListResponse, +} from './endpoints/types'; +import { + GriptapeEndpointInputSchemas, + GriptapeEndpointOutputSchemas, +} from './endpoints/types'; +import type { GriptapeContext } from './index'; +import { griptape } from './index'; + +jest.mock('corsair/http', () => ({ + ...jest.requireActual('corsair/http'), + request: jest.fn(), +})); + +jest.mock('corsair/core', () => ({ + ...jest.requireActual('corsair/core'), + logEventFromContext: jest.fn(async () => undefined), +})); + +const mockRequest = request as jest.MockedFunction; +const mockLog = jest.mocked(logEventFromContext); + +describe('Griptape Plugin API', () => { + const apiKey = 'test-api-key'; + + const griptapePlugin = griptape({ key: apiKey }); + const endpoints = griptapePlugin.endpoints; + if (!endpoints) throw new Error('griptape plugin must expose endpoints'); + + // Narrow assertion: the handlers under test only read ctx.key, and + // logEventFromContext is mocked above, so a partial context is safe here. + // A full context would require constructing the encrypted key manager. + const ctx = { key: apiKey } as unknown as GriptapeContext; + + beforeEach(() => { + mockRequest.mockReset(); + mockLog.mockClear(); + }); + + describe('assistant.list', () => { + it('sends GET /assistants with pagination parameters', async () => { + const mockResponse: AssistantListResponse = { + assistants: [ + { + assistant_id: '550e8400-e29b-41d4-a716-446655440000', + created_at: '2026-01-01T00:00:00Z', + created_by: 'user@example.com', + description: 'Test assistant', + input: 'text', + knowledge_base_ids: [], + model: 'gpt-5', + name: 'Test Assistant', + organization_id: '550e8400-e29b-41d4-a716-446655440001', + retriever_ids: [], + ruleset_ids: [], + structure_ids: [], + tool_ids: [], + updated_at: '2026-01-01T00:00:00Z', + }, + ], + pagination: { + page_number: 1, + page_size: 10, + total_count: 1, + total_pages: 1, + next_page: 2, + previous_page: 0, + }, + }; + + mockRequest.mockResolvedValueOnce(mockResponse); + + const result = await endpoints.assistant.list(ctx, { + page: 1, + page_size: 10, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + BASE: 'https://cloud.griptape.ai/api', + HEADERS: expect.objectContaining({ + Authorization: `Bearer ${apiKey}`, + }), + }), + expect.objectContaining({ + method: 'GET', + url: 'assistants', + query: { + page: 1, + page_size: 10, + }, + }), + ); + + expect(result).toEqual(mockResponse); + }); + + it('lists assistants without optional pagination parameters', async () => { + const mockResponse: AssistantListResponse = { + assistants: [], + pagination: { + page_number: 1, + page_size: 20, + total_count: 0, + total_pages: 0, + }, + }; + + mockRequest.mockResolvedValueOnce(mockResponse); + + const result = await endpoints.assistant.list(ctx, {}); + + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: 'assistants', + query: { + page: undefined, + page_size: undefined, + }, + }), + ); + + expect(result).toEqual(mockResponse); + }); + }); + + describe('assistant.get', () => { + it('sends GET /assistants/{assistant_id}', async () => { + const mockResponse: AssistantGetResponse = { + assistant_id: '550e8400-e29b-41d4-a716-446655440000', + created_at: '2026-01-01T00:00:00Z', + created_by: 'user@example.com', + description: 'Test assistant', + knowledge_base_ids: [], + name: 'Test Assistant', + organization_id: '550e8400-e29b-41d4-a716-446655440001', + retriever_ids: [], + ruleset_ids: [], + structure_ids: [], + tool_ids: [], + updated_at: '2026-01-01T00:00:00Z', + }; + + mockRequest.mockResolvedValueOnce(mockResponse); + + const result = await endpoints.assistant.get(ctx, { + assistant_id: '550e8400-e29b-41d4-a716-446655440000', + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + BASE: 'https://cloud.griptape.ai/api', + HEADERS: expect.objectContaining({ + Authorization: `Bearer ${apiKey}`, + }), + }), + expect.objectContaining({ + method: 'GET', + url: 'assistants/550e8400-e29b-41d4-a716-446655440000', + }), + ); + + expect(result).toEqual(mockResponse); + }); + + it('propagates ApiError unchanged so status-based handlers keep working', async () => { + const requestOptions = { + method: 'GET' as const, + url: 'assistants/550e8400-e29b-41d4-a716-446655440000', + }; + const rateLimitError = new ApiError( + requestOptions, + { + url: 'https://cloud.griptape.ai/api/assistants/550e8400-e29b-41d4-a716-446655440000', + ok: false, + status: 429, + statusText: 'Too Many Requests', + body: { message: 'Too Many Requests' }, + }, + 'Too Many Requests', + { retryAfter: 30000 }, + ); + + mockRequest.mockRejectedValueOnce(rateLimitError); + + await expect( + endpoints.assistant.get(ctx, { + assistant_id: '550e8400-e29b-41d4-a716-446655440000', + }), + ).rejects.toBe(rateLimitError); + expect(mockLog).not.toHaveBeenCalled(); + }); + + it('rejects non-UUID assistant ids at the input schema boundary', () => { + const result = GriptapeEndpointInputSchemas.assistantGet.safeParse({ + assistant_id: 'not-a-uuid', + }); + + expect(result.success).toBe(false); + }); + }); + + describe('endpoint schemas', () => { + it('validates well-formed list responses through the output schema', () => { + const parsed = GriptapeEndpointOutputSchemas.assistantList.safeParse({ + assistants: [], + pagination: { + page_number: 2, + page_size: 20, + total_count: 41, + total_pages: 3, + next_page: 3, + previous_page: 1, + }, + }); + + expect(parsed.success).toBe(true); + }); + + it('rejects responses with missing pagination fields', () => { + const parsed = GriptapeEndpointOutputSchemas.assistantList.safeParse({ + assistants: [], + }); + + expect(parsed.success).toBe(false); + }); + + it('rejects detail payloads where ids are not UUIDs', () => { + const parsed = GriptapeEndpointOutputSchemas.assistantGet.safeParse({ + assistant_id: 'garbage-id', + created_at: '2026-01-01T00:00:00Z', + created_by: 'user@example.com', + description: 'Test assistant', + knowledge_base_ids: [], + name: 'Test Assistant', + organization_id: '550e8400-e29b-41d4-a716-446655440001', + retriever_ids: [], + ruleset_ids: [], + structure_ids: [], + tool_ids: [], + updated_at: '2026-01-01T00:00:00Z', + }); + + expect(parsed.success).toBe(false); + }); + }); +}); diff --git a/packages/griptape/endpoints/assistant-get.ts b/packages/griptape/endpoints/assistant-get.ts new file mode 100644 index 000000000..349e854da --- /dev/null +++ b/packages/griptape/endpoints/assistant-get.ts @@ -0,0 +1,20 @@ +import { logEventFromContext } from 'corsair/core'; +import type { GriptapeEndpointOutputs, GriptapeEndpoints } from '..'; +import { makeGriptapeRequest } from '../client'; + +export const get: GriptapeEndpoints['assistantGet'] = async (ctx, input) => { + const response = await makeGriptapeRequest< + GriptapeEndpointOutputs['assistantGet'] + >(`assistants/${input.assistant_id}`, ctx.key, { + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'griptape.assistant.get', + { ...input }, + 'completed', + ); + + return response; +}; diff --git a/packages/griptape/endpoints/assistant-list.ts b/packages/griptape/endpoints/assistant-list.ts new file mode 100644 index 000000000..58818f89e --- /dev/null +++ b/packages/griptape/endpoints/assistant-list.ts @@ -0,0 +1,24 @@ +import { logEventFromContext } from 'corsair/core'; +import type { GriptapeEndpointOutputs, GriptapeEndpoints } from '..'; +import { makeGriptapeRequest } from '../client'; + +export const list: GriptapeEndpoints['assistantList'] = async (ctx, input) => { + const response = await makeGriptapeRequest< + GriptapeEndpointOutputs['assistantList'] + >('assistants', ctx.key, { + method: 'GET', + query: { + page: input.page, + page_size: input.page_size, + }, + }); + + await logEventFromContext( + ctx, + 'griptape.assistant.list', + { ...input }, + 'completed', + ); + + return response; +}; diff --git a/packages/griptape/endpoints/endpoint.test.ts b/packages/griptape/endpoints/endpoint.test.ts new file mode 100644 index 000000000..86dfeac0c --- /dev/null +++ b/packages/griptape/endpoints/endpoint.test.ts @@ -0,0 +1,18 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('griptape endpoints scaffold', () => { + it('does not keep the generator example file', () => { + expect(existsSync(join(__dirname, 'example.ts'))).toBe(false); + }); + + it('wires assistant list and get endpoints', () => { + const src = readFileSync(join(__dirname, 'index.ts'), 'utf8'); + + expect(src).toContain("from './assistant-list'"); + expect(src).toContain("from './assistant-get'"); + expect(src).toContain('list: assistantList'); + expect(src).toContain('get: assistantGet'); + expect(src).not.toContain("from './example'"); + }); +}); diff --git a/packages/griptape/endpoints/index.ts b/packages/griptape/endpoints/index.ts new file mode 100644 index 000000000..5165fa49e --- /dev/null +++ b/packages/griptape/endpoints/index.ts @@ -0,0 +1,9 @@ +import { get as assistantGet } from './assistant-get'; +import { list as assistantList } from './assistant-list'; + +export const Assistant = { + list: assistantList, + get: assistantGet, +}; + +export * from './types'; diff --git a/packages/griptape/endpoints/types.ts b/packages/griptape/endpoints/types.ts new file mode 100644 index 000000000..b94ffd20f --- /dev/null +++ b/packages/griptape/endpoints/types.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; + +export const AssistantDetailSchema = z.object({ + assistant_id: z.uuid(), + created_at: z.string().datetime(), + created_by: z.string(), + description: z.string(), + input: z.string().optional(), + knowledge_base_ids: z.array(z.uuid()), + model: z.string().optional(), + name: z.string(), + organization_id: z.uuid(), + retriever_ids: z.array(z.uuid()), + ruleset_ids: z.array(z.uuid()), + structure_ids: z.array(z.uuid()), + tool_ids: z.array(z.uuid()), + updated_at: z.string().datetime(), +}); + +const AssistantGetInputSchema = z.object({ + assistant_id: z.uuid(), +}); + +const AssistantGetResponseSchema = AssistantDetailSchema; + +const PaginationSchema = z.object({ + next_page: z.number().optional(), + page_number: z.number(), + page_size: z.number(), + previous_page: z.number().optional(), + total_count: z.number(), + total_pages: z.number(), +}); + +const AssistantListInputSchema = z.object({ + page: z.number().optional(), + page_size: z.number().optional(), +}); + +const AssistantListResponseSchema = z.object({ + assistants: z.array(AssistantDetailSchema), + pagination: PaginationSchema, +}); + +export type AssistantListInput = z.infer; + +export type AssistantListResponse = z.infer; + +export type AssistantGetInput = z.infer; + +export type AssistantGetResponse = z.infer; + +export type GriptapeEndpointInputs = { + assistantList: AssistantListInput; + assistantGet: AssistantGetInput; +}; + +export type GriptapeEndpointOutputs = { + assistantList: AssistantListResponse; + assistantGet: AssistantGetResponse; +}; + +export const GriptapeEndpointInputSchemas = { + assistantList: AssistantListInputSchema, + assistantGet: AssistantGetInputSchema, +} as const; + +export const GriptapeEndpointOutputSchemas = { + assistantList: AssistantListResponseSchema, + assistantGet: AssistantGetResponseSchema, +} as const; diff --git a/packages/griptape/error-handlers.test.ts b/packages/griptape/error-handlers.test.ts new file mode 100644 index 000000000..a9263983c --- /dev/null +++ b/packages/griptape/error-handlers.test.ts @@ -0,0 +1,141 @@ +import type { ErrorContext } from 'corsair/core'; +import type { ApiRequestOptions } from 'corsair/http'; +import { ApiError } from 'corsair/http'; +import { errorHandlers } from './error-handlers'; + +const sampleRequest: ApiRequestOptions = { + method: 'GET', + url: 'assistants', +}; + +function apiErrorOf( + status: number, + statusText: string, + retryAfterMs?: number, +): ApiError { + return new ApiError( + sampleRequest, + { + url: 'https://cloud.griptape.ai/api/assistants', + ok: false, + status, + statusText, + body: { message: statusText }, + }, + statusText, + retryAfterMs === undefined ? undefined : { retryAfter: retryAfterMs }, + ); +} + +const errorContext: ErrorContext = { + pluginId: 'griptape', + operation: 'assistant.list', + input: {}, + originalError: new Error('original'), +}; + +type ErrorHandlerKey = 'RATE_LIMIT_ERROR' | 'AUTH_ERROR' | 'DEFAULT'; + +function handlerFor(key: ErrorHandlerKey) { + const entry = errorHandlers[key]; + if (!entry) throw new Error(`missing ${key} error handler`); + return entry; +} + +describe('griptape error handlers', () => { + describe('RATE_LIMIT_ERROR', () => { + it('matches ApiError with status 429', () => { + expect( + handlerFor('RATE_LIMIT_ERROR').match( + apiErrorOf(429, 'x'), + errorContext, + ), + ).toBe(true); + }); + + it('matches rate-limit text on plain errors', () => { + expect( + handlerFor('RATE_LIMIT_ERROR').match( + new Error('rate_limited: slow down'), + errorContext, + ), + ).toBe(true); + }); + + it('does not match unrelated errors', () => { + expect( + handlerFor('RATE_LIMIT_ERROR').match( + new Error('not found'), + errorContext, + ), + ).toBe(false); + }); + + it('passes the provider Retry-After through to the retry policy', async () => { + const policy = await handlerFor('RATE_LIMIT_ERROR').handler( + apiErrorOf(429, 'Too Many Requests', 45000), + errorContext, + ); + + expect(policy).toEqual({ maxRetries: 5, headersRetryAfterMs: 45000 }); + }); + + it('omits Retry-After when the provider does not send one', async () => { + const policy = await handlerFor('RATE_LIMIT_ERROR').handler( + apiErrorOf(429, 'Too Many Requests'), + errorContext, + ); + + expect(policy).toEqual({ maxRetries: 5, headersRetryAfterMs: undefined }); + }); + }); + + describe('AUTH_ERROR', () => { + it('matches ApiError with status 401', () => { + expect( + handlerFor('AUTH_ERROR').match(apiErrorOf(401, 'x'), errorContext), + ).toBe(true); + }); + + it('matches unauthorized text on plain errors', () => { + expect( + handlerFor('AUTH_ERROR').match( + new Error('unauthorized key'), + errorContext, + ), + ).toBe(true); + }); + + it('does not match 403 errors', () => { + expect( + handlerFor('AUTH_ERROR').match(apiErrorOf(403, 'x'), errorContext), + ).toBe(false); + }); + + it('does not retry auth failures', async () => { + const policy = await handlerFor('AUTH_ERROR').handler( + apiErrorOf(401, 'Unauthorized'), + errorContext, + ); + + expect(policy).toEqual({ maxRetries: 0 }); + }); + }); + + describe('DEFAULT', () => { + it('matches every error', () => { + expect( + handlerFor('DEFAULT').match(new Error('anything'), errorContext), + ).toBe(true); + }); + + it('does not retry by default', async () => { + const policy = await handlerFor('DEFAULT').handler( + new Error('boom'), + errorContext, + ); + + expect(policy).toEqual({ maxRetries: 0 }); + }); + }); +}); diff --git a/packages/griptape/error-handlers.ts b/packages/griptape/error-handlers.ts new file mode 100644 index 000000000..ac3cc9f12 --- /dev/null +++ b/packages/griptape/error-handlers.ts @@ -0,0 +1,33 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +// Annotated (not `satisfies`) so match/handler keep the full canonical +// signature — callers and tests pass the error through uniformly. +export const errorHandlers: CorsairErrorHandler = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('rate_limited') || msg.includes('429'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +}; diff --git a/packages/griptape/index.ts b/packages/griptape/index.ts new file mode 100644 index 000000000..9a7272e04 --- /dev/null +++ b/packages/griptape/index.ts @@ -0,0 +1,172 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; + +import { Assistant } from './endpoints'; +import type { + GriptapeEndpointInputs, + GriptapeEndpointOutputs, +} from './endpoints/types'; +import { + GriptapeEndpointInputSchemas, + GriptapeEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { GriptapeSchema } from './schema'; + +export type GriptapePluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalGriptapePlugin['hooks']; + webhookHooks?: InternalGriptapePlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type GriptapeContext = CorsairPluginContext< + typeof GriptapeSchema, + GriptapePluginOptions +>; + +export type GriptapeKeyBuilderContext = + KeyBuilderContext; + +export type GriptapeBoundEndpoints = BindEndpoints< + typeof griptapeEndpointsNested +>; + +type GriptapeEndpoint = + CorsairEndpoint< + GriptapeContext, + GriptapeEndpointInputs[K], + GriptapeEndpointOutputs[K] + >; + +export type GriptapeEndpoints = { + assistantList: GriptapeEndpoint<'assistantList'>; + assistantGet: GriptapeEndpoint<'assistantGet'>; +}; + +export type GriptapeWebhooks = Record; + +export type GriptapeBoundWebhooks = Record; + +const griptapeEndpointsNested = { + assistant: { + list: Assistant.list, + get: Assistant.get, + }, +} as const; + +const griptapeWebhooksNested = {} as const; + +export const griptapeEndpointSchemas = { + 'assistant.list': { + input: GriptapeEndpointInputSchemas.assistantList, + output: GriptapeEndpointOutputSchemas.assistantList, + }, + 'assistant.get': { + input: GriptapeEndpointInputSchemas.assistantGet, + output: GriptapeEndpointOutputSchemas.assistantGet, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof griptapeEndpointsNested +>; + +const griptapeWebhookSchemas = + {} as const satisfies RequiredPluginWebhookSchemas< + typeof griptapeWebhooksNested + >; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const griptapeEndpointMeta = { + 'assistant.list': { + riskLevel: 'read', + description: 'List assistants', + }, + 'assistant.get': { + riskLevel: 'read', + description: 'Get an assistant', + }, +} as const satisfies RequiredPluginEndpointMeta; + +export const griptapeAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseGriptapePlugin = CorsairPlugin< + 'griptape', + typeof GriptapeSchema, + typeof griptapeEndpointsNested, + typeof griptapeWebhooksNested, + T, + typeof defaultAuthType +>; + +export type InternalGriptapePlugin = BaseGriptapePlugin; + +export type ExternalGriptapePlugin = + BaseGriptapePlugin; + +export function griptape( + incomingOptions: GriptapePluginOptions & T = {} as GriptapePluginOptions & T, +): ExternalGriptapePlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'griptape', + authConfig: griptapeAuthConfig, + schema: GriptapeSchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: griptapeEndpointsNested, + webhooks: griptapeWebhooksNested, + endpointMeta: griptapeEndpointMeta, + endpointSchemas: griptapeEndpointSchemas, + webhookSchemas: griptapeWebhookSchemas, + pluginWebhookMatcher: () => false, + + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + + keyBuilder: async (ctx: GriptapeKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalGriptapePlugin; +} + +export type { + AssistantListInput, + AssistantListResponse, + GriptapeEndpointInputs, + GriptapeEndpointOutputs, +} from './endpoints/types'; diff --git a/packages/griptape/jest.config.cjs b/packages/griptape/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/griptape/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/griptape/package.json b/packages/griptape/package.json new file mode 100644 index 000000000..a04057254 --- /dev/null +++ b/packages/griptape/package.json @@ -0,0 +1,45 @@ +{ + "name": "@corsair-dev/griptape", + "version": "0.1.0", + "description": "Griptape plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rimraf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "rimraf": "^6.1.3", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "griptape", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/griptape/schema.test.ts b/packages/griptape/schema.test.ts new file mode 100644 index 000000000..d69565682 --- /dev/null +++ b/packages/griptape/schema.test.ts @@ -0,0 +1,17 @@ +import { GriptapeSchema } from './schema'; + +describe('Griptape schema', () => { + it('declares a semver version', () => { + expect(GriptapeSchema.version).toBeDefined(); + expect(GriptapeSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof GriptapeSchema.entities).toBe('object'); + expect(GriptapeSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(GriptapeSchema.entities))).toBe(true); + for (const entity of Object.values(GriptapeSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); diff --git a/packages/griptape/schema/database.ts b/packages/griptape/schema/database.ts new file mode 100644 index 000000000..def23ef05 --- /dev/null +++ b/packages/griptape/schema/database.ts @@ -0,0 +1,3 @@ +// Griptape has no persisted database entities yet. Assistant metadata is +// fetched live from the Griptape Cloud API on each call, so this plugin's +// schema declares an empty entity set. diff --git a/packages/griptape/schema/index.ts b/packages/griptape/schema/index.ts new file mode 100644 index 000000000..9bb06c397 --- /dev/null +++ b/packages/griptape/schema/index.ts @@ -0,0 +1,4 @@ +export const GriptapeSchema = { + version: '1.0.0', + entities: {}, +} as const; diff --git a/packages/griptape/tsconfig.json b/packages/griptape/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/griptape/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/griptape/tsup.config.ts b/packages/griptape/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/griptape/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18845437d..ac8555ac5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,7 +130,7 @@ importers: version: link:../../packages/slack corsair: specifier: ^0.1.4 - version: 0.1.119(postgres@3.4.7)(react@19.2.7) + version: link:../../packages/corsair dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -2926,6 +2926,33 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/griptape: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/groqcloud: devDependencies: '@types/jest': @@ -6221,36 +6248,6 @@ packages: '@codemirror/view@6.43.3': resolution: {integrity: sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng==} - '@corsair-dev/frpc-darwin-arm64@0.1.117': - resolution: {integrity: sha512-PrxfqHlRKzm5xW5J2wnZ1knVF4xV6svgmeo11YvHMAAUqsNBY6mczMQFlu1HJcMvZ9yysqMiTZBKdjAC+DnhlQ==} - cpu: [arm64] - os: [darwin] - - '@corsair-dev/frpc-darwin-x64@0.1.117': - resolution: {integrity: sha512-8GZP5G5kPhu/TPGLnSgecvyyxXB6+ZdwDmXAhz3PvqiE8mmQ2PRa7FaoVYdOa4nY8XWa5YmnXC/DcjsTLIfPmQ==} - cpu: [x64] - os: [darwin] - - '@corsair-dev/frpc-linux-arm64@0.1.117': - resolution: {integrity: sha512-TwVeRBYd17hy6YJByCUouMVBo2itK4fPuvm3TPwdq5ZVxN64PEKE2KHQIo+uW7opxcMXre6DVSzLQyFmrqI/Xg==} - cpu: [arm64] - os: [linux] - - '@corsair-dev/frpc-linux-x64@0.1.117': - resolution: {integrity: sha512-Ch98qSFQXYSA6sg2ysTKrRCZ08tNWfXjSEHKT3nMM7vYhuDXkQcBmXM4tbPEO8SI6BzLa9/g1lsXT5T0hx48dQ==} - cpu: [x64] - os: [linux] - - '@corsair-dev/frpc-win32-arm64@0.1.117': - resolution: {integrity: sha512-QyLWoaVNrq1C5V81d7DIOLm63n4+0YSKcKJK1MUTdaQAEroaejybeAuskiYhq11LCJ2wb+WTkv3qaWF0yLgg6g==} - cpu: [arm64] - os: [win32] - - '@corsair-dev/frpc-win32-x64@0.1.117': - resolution: {integrity: sha512-V4sI1JUwlNBi9nAJHedXPXzmivfi5DHaU2yJMwnf++5PQvVcxRMJQcec1p9+Q8bPyAZ7gtq+4XdRuBJVZXdFkg==} - cpu: [x64] - os: [win32] - '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -11305,14 +11302,6 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} - corsair@0.1.119: - resolution: {integrity: sha512-Cl86/VhMNOdzTbUzmeb9RwlhuucojM1gEmgTQJsQYdcxP2qFiPPly2VGK9M2h5GkHLZ3kZ4HRALTQP3wIhepBQ==} - peerDependencies: - react: '>=18.0.0' - peerDependenciesMeta: - react: - optional: true - cosmiconfig@9.0.1: resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} @@ -17700,24 +17689,6 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@corsair-dev/frpc-darwin-arm64@0.1.117': - optional: true - - '@corsair-dev/frpc-darwin-x64@0.1.117': - optional: true - - '@corsair-dev/frpc-linux-arm64@0.1.117': - optional: true - - '@corsair-dev/frpc-linux-x64@0.1.117': - optional: true - - '@corsair-dev/frpc-win32-arm64@0.1.117': - optional: true - - '@corsair-dev/frpc-win32-x64@0.1.117': - optional: true - '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -23251,23 +23222,6 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - corsair@0.1.119(postgres@3.4.7)(react@19.2.7): - dependencies: - kysely: 0.28.17 - kysely-postgres-js: 3.0.0(kysely@0.28.17)(postgres@3.4.7) - uuid: 13.0.0 - zod: 4.4.3 - optionalDependencies: - '@corsair-dev/frpc-darwin-arm64': 0.1.117 - '@corsair-dev/frpc-darwin-x64': 0.1.117 - '@corsair-dev/frpc-linux-arm64': 0.1.117 - '@corsair-dev/frpc-linux-x64': 0.1.117 - '@corsair-dev/frpc-win32-arm64': 0.1.117 - '@corsair-dev/frpc-win32-x64': 0.1.117 - react: 19.2.7 - transitivePeerDependencies: - - postgres - cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -25495,12 +25449,6 @@ snapshots: kleur@4.1.5: {} - kysely-postgres-js@3.0.0(kysely@0.28.17)(postgres@3.4.7): - dependencies: - kysely: 0.28.17 - optionalDependencies: - postgres: 3.4.7 - kysely-postgres-js@3.0.0(kysely@0.28.9)(postgres@3.4.7): dependencies: kysely: 0.28.9