Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/corsair/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const BaseProviders = [
'googlemeet',
'googlesheets',
'grafana',
'griptape',
'groqcloud',
'habitica',
'hackernews',
Expand Down Expand Up @@ -289,6 +290,7 @@ export const ProviderDisplayNames = {
googlemeet: 'Google Meet',
googlesheets: 'Google Sheets',
grafana: 'Grafana',
griptape: 'Griptape',
groqcloud: 'GroqCloud',
habitica: 'Habitica',
hackernews: 'Hacker News',
Expand Down Expand Up @@ -473,6 +475,7 @@ export type AllProviders =
| 'googlemeet'
| 'googlesheets'
| 'grafana'
| 'griptape'
| 'groqcloud'
| 'habitica'
| 'hackernews'
Expand Down
16 changes: 16 additions & 0 deletions packages/griptape/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# @corsair-dev/griptape

Corsair integration for the Griptape Cloud API.

## Authentication

Griptape Cloud uses HTTP Bearer authentication.

Provide a Griptape Cloud API key when creating the plugin:

```ts
import { griptape } from '@corsair-dev/griptape';

const plugin = griptape({
key: process.env.GT_CLOUD_API_KEY,
});
94 changes: 94 additions & 0 deletions packages/griptape/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof request>;

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'));
});
});
64 changes: 64 additions & 0 deletions packages/griptape/client.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
endpoint: string,
apiKey: string,
options: {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
body?: Record<string, unknown>;
query?: Record<string, string | number | boolean | undefined>;
} = {},
): Promise<T> {
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<T>(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);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
throw new GriptapeAPIError('Unknown error');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Loading
Loading