Skip to content
This repository was archived by the owner on Aug 5, 2025. It is now read-only.

Commit aaaa54c

Browse files
authored
feat(attachments): create context-aware helper for attachments (#44)
* wip * wip * feat(attachments): create context-aware helper for attachments * fix: tests * fix: tests * fix: review
1 parent 28fc8c4 commit aaaa54c

File tree

7 files changed

+205
-83
lines changed

7 files changed

+205
-83
lines changed

src/api.ts

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import axios, { AxiosError } from 'axios';
22
import FormData from 'form-data';
33
import { createReadStream } from 'fs';
4+
import { ReadStream } from 'fs';
45
import { v4 as uuidv4 } from 'uuid';
56

67
import { LiteralClient } from '.';
@@ -21,6 +22,7 @@ import {
2122
PersistedGeneration
2223
} from './generation';
2324
import {
25+
Attachment,
2426
CleanThreadFields,
2527
Dataset,
2628
DatasetExperiment,
@@ -327,6 +329,28 @@ function addGenerationsToDatasetQueryBuilder(generationIds: string[]) {
327329
`;
328330
}
329331

332+
type UploadFileBaseParams = {
333+
id?: Maybe<string>;
334+
threadId?: string;
335+
mime?: Maybe<string>;
336+
};
337+
type UploadFileParamsWithPath = UploadFileBaseParams & {
338+
path: string;
339+
};
340+
type UploadFileParamsWithContent = UploadFileBaseParams & {
341+
content:
342+
| ReadableStream<any>
343+
| ReadStream
344+
| Buffer
345+
| File
346+
| Blob
347+
| ArrayBuffer;
348+
};
349+
type CreateAttachmentParams = {
350+
name?: string;
351+
metadata?: Maybe<Record<string, any>>;
352+
};
353+
330354
export class API {
331355
/** @ignore */
332356
private client: LiteralClient;
@@ -596,19 +620,25 @@ export class API {
596620
* @returns An object containing the `objectKey` of the uploaded file and the signed `url`, or `null` values if the upload fails.
597621
* @throws {Error} Throws an error if neither `content` nor `path` is provided, or if the server response is invalid.
598622
*/
623+
624+
async uploadFile(params: UploadFileParamsWithContent): Promise<{
625+
objectKey: Maybe<string>;
626+
url: Maybe<string>;
627+
}>;
628+
async uploadFile(params: UploadFileParamsWithPath): Promise<{
629+
objectKey: Maybe<string>;
630+
url: Maybe<string>;
631+
}>;
599632
async uploadFile({
600633
content,
601634
path,
602635
id,
603636
threadId,
604637
mime
605-
}: {
606-
content?: Maybe<any>;
607-
path?: Maybe<string>;
608-
id?: Maybe<string>;
609-
threadId: string;
610-
mime?: Maybe<string>;
611-
}) {
638+
}: UploadFileParamsWithContent & UploadFileParamsWithPath): Promise<{
639+
objectKey: Maybe<string>;
640+
url: Maybe<string>;
641+
}> {
612642
if (!content && !path) {
613643
throw new Error('Either content or path must be provided');
614644
}
@@ -678,6 +708,52 @@ export class API {
678708
}
679709
}
680710

711+
async createAttachment(
712+
params: UploadFileParamsWithContent & CreateAttachmentParams
713+
): Promise<Attachment>;
714+
async createAttachment(
715+
params: UploadFileParamsWithPath & CreateAttachmentParams
716+
): Promise<Attachment>;
717+
async createAttachment(
718+
params: UploadFileParamsWithContent &
719+
UploadFileParamsWithPath &
720+
CreateAttachmentParams
721+
): Promise<Attachment> {
722+
if (params.content instanceof Blob) {
723+
params.content = Buffer.from(await params.content.arrayBuffer());
724+
}
725+
if (params.content instanceof ArrayBuffer) {
726+
params.content = Buffer.from(params.content);
727+
}
728+
729+
const threadFromStore = this.client._currentThread();
730+
const stepFromStore = this.client._currentStep();
731+
732+
if (threadFromStore) {
733+
params.threadId = threadFromStore.id;
734+
}
735+
736+
const { objectKey, url } = await this.uploadFile(params);
737+
738+
const attachment = new Attachment({
739+
name: params.name,
740+
objectKey,
741+
mime: params.mime,
742+
metadata: params.metadata,
743+
url
744+
});
745+
746+
if (stepFromStore) {
747+
if (!stepFromStore.attachments) {
748+
stepFromStore.attachments = [];
749+
}
750+
751+
stepFromStore.attachments.push(attachment);
752+
}
753+
754+
return attachment;
755+
}
756+
681757
// Generation
682758
/**
683759
* Retrieves a paginated list of Generations based on the provided filters and sorting order.

src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ export class LiteralClient {
4949
return this.step({ ...data, type: 'run' });
5050
}
5151

52+
_currentThread(): Thread | null {
53+
const store = storage.getStore();
54+
55+
return store?.currentThread || null;
56+
}
57+
58+
_currentStep(): Step | null {
59+
const store = storage.getStore();
60+
61+
return store?.currentStep || null;
62+
}
63+
5264
/**
5365
* Gets the current thread from the context.
5466
* WARNING : this will throw if run outside of a thread context.

src/instrumentation/openai.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ import {
1313
IGenerationMessage,
1414
LiteralClient,
1515
Maybe,
16-
Step,
17-
StepConstructor,
18-
Thread
16+
StepConstructor
1917
} from '..';
2018

2119
// Define a generic type for the original function to be wrapped
@@ -310,25 +308,13 @@ const processOpenAIOutput = async (
310308
tags: tags
311309
};
312310

313-
let threadFromStore: Thread | null = null;
314-
try {
315-
threadFromStore = client.getCurrentThread();
316-
} catch (error) {
317-
// Ignore error thrown if getCurrentThread is called outside of a context
318-
}
319-
320-
let stepFromStore: Step | null = null;
321-
try {
322-
stepFromStore = client.getCurrentStep();
323-
} catch (error) {
324-
// Ignore error thrown if getCurrentStep is called outside of a context
325-
}
311+
const threadFromStore = client._currentThread();
312+
const stepFromStore = client._currentStep();
326313

327314
const parent = stepFromStore || threadFromStore;
328315

329316
if ('data' in output) {
330317
// Image Generation
331-
332318
const stepData: StepConstructor = {
333319
name: inputs.model || 'openai',
334320
type: 'llm',

src/openai.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,21 +42,16 @@ class OpenAIAssistantSyncer {
4242
);
4343
const mime = 'image/png';
4444

45-
const { objectKey } = await this.client.api.uploadFile({
46-
threadId: litThreadId,
47-
id: attachmentId,
48-
content: file.body,
49-
mime
50-
});
51-
52-
const attachment = new Attachment({
53-
name: content.image_file.file_id,
54-
id: attachmentId,
55-
objectKey,
56-
mime
57-
});
58-
59-
attachments.push(attachment);
45+
if (file.body) {
46+
const attachment = await this.client.api.createAttachment({
47+
threadId: litThreadId,
48+
id: attachmentId,
49+
content: file.body,
50+
mime
51+
});
52+
53+
attachments.push(attachment);
54+
}
6055
} else if (content.type === 'text') {
6156
output.content += content.text.value;
6257
}

tests/api.test.ts

Lines changed: 1 addition & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
import 'dotenv/config';
2-
import { createReadStream } from 'fs';
32
import { v4 as uuidv4 } from 'uuid';
43

5-
import {
6-
Attachment,
7-
ChatGeneration,
8-
Dataset,
9-
LiteralClient,
10-
Score
11-
} from '../src';
4+
import { ChatGeneration, Dataset, LiteralClient, Score } from '../src';
125

136
describe('End to end tests for the SDK', function () {
147
let client: LiteralClient;
@@ -342,42 +335,6 @@ describe('End to end tests for the SDK', function () {
342335
expect(scores[1].scorer).toBe('openai:gpt-3.5-turbo');
343336
});
344337

345-
it('should test attachment', async function () {
346-
const thread = await client.thread({ id: uuidv4() });
347-
// Upload an attachment
348-
const fileStream = createReadStream('./tests/chainlit-logo.png');
349-
const mime = 'image/png';
350-
351-
const { objectKey } = await client.api.uploadFile({
352-
threadId: thread.id,
353-
content: fileStream,
354-
mime
355-
});
356-
357-
const attachment = new Attachment({
358-
name: 'test',
359-
objectKey,
360-
mime
361-
});
362-
363-
const step = await thread
364-
.step({
365-
name: 'test',
366-
type: 'run',
367-
attachments: [attachment]
368-
})
369-
.send();
370-
371-
await new Promise((resolve) => setTimeout(resolve, 1000));
372-
373-
const fetchedStep = await client.api.getStep(step.id!);
374-
expect(fetchedStep?.attachments?.length).toBe(1);
375-
expect(fetchedStep?.attachments![0].objectKey).toBe(objectKey);
376-
expect(fetchedStep?.attachments![0].url).toBeDefined();
377-
378-
await client.api.deleteThread(thread.id);
379-
});
380-
381338
it('should get project id', async () => {
382339
const projectId = await client.api.getProjectId();
383340
expect(projectId).toEqual(expect.any(String));

tests/attachments.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import 'dotenv/config';
2+
import { createReadStream, readFileSync } from 'fs';
3+
4+
import { Attachment, LiteralClient, Maybe } from '../src';
5+
6+
const url = process.env.LITERAL_API_URL;
7+
const apiKey = process.env.LITERAL_API_KEY;
8+
if (!url || !apiKey) {
9+
throw new Error('Missing environment variables');
10+
}
11+
const client = new LiteralClient(apiKey, url);
12+
13+
const filePath = './tests/chainlit-logo.png';
14+
const mime = 'image/png';
15+
16+
function removeVariableParts(url: string) {
17+
return url.split('X-Amz-Date')[0].split('X-Goog-Date')[0];
18+
}
19+
20+
describe('Attachments', () => {
21+
describe('Uploading a file', () => {
22+
const stream = createReadStream(filePath);
23+
const buffer = readFileSync(filePath);
24+
const arrayBuffer = buffer.buffer;
25+
const blob = new Blob([buffer]);
26+
// We wrap the blob in a blob and simulate the structure of a File
27+
const file = new Blob([blob], { type: 'image/png' });
28+
29+
it.each([
30+
{ type: 'Stream', content: stream! },
31+
{ type: 'Buffer', content: buffer! },
32+
{ type: 'ArrayBuffer', content: arrayBuffer! },
33+
{ type: 'Blob', content: blob! },
34+
{ type: 'File', content: file! }
35+
])('handles $type objects', async function ({ type, content }) {
36+
const attachment = await client.api.createAttachment({
37+
content,
38+
mime,
39+
name: `Attachment ${type}`,
40+
metadata: { type }
41+
});
42+
43+
const step = await client
44+
.run({
45+
name: `Test ${type}`,
46+
attachments: [attachment]
47+
})
48+
.send();
49+
50+
await new Promise((resolve) => setTimeout(resolve, 1000));
51+
52+
const fetchedStep = await client.api.getStep(step.id!);
53+
54+
const urlWithoutVariables = removeVariableParts(attachment.url!);
55+
const fetchedUrlWithoutVariables = removeVariableParts(
56+
fetchedStep?.attachments![0].url as string
57+
);
58+
59+
expect(fetchedStep?.attachments?.length).toBe(1);
60+
expect(fetchedStep?.attachments![0].objectKey).toEqual(
61+
attachment.objectKey
62+
);
63+
expect(fetchedStep?.attachments![0].name).toEqual(attachment.name);
64+
expect(fetchedStep?.attachments![0].metadata).toEqual(
65+
attachment.metadata
66+
);
67+
expect(urlWithoutVariables).toEqual(fetchedUrlWithoutVariables);
68+
});
69+
});
70+
71+
describe('Handling context', () => {
72+
it('attaches the attachment to the step in the context', async () => {
73+
const stream = createReadStream(filePath);
74+
75+
let stepId: Maybe<string>;
76+
let attachment: Maybe<Attachment>;
77+
78+
await client.run({ name: 'Attachment test ' }).wrap(async () => {
79+
stepId = client.getCurrentStep().id!;
80+
attachment = await client.api.createAttachment({
81+
content: stream!,
82+
mime,
83+
name: 'Attachment',
84+
metadata: { type: 'Stream' }
85+
});
86+
});
87+
88+
await new Promise((resolve) => setTimeout(resolve, 1000));
89+
90+
const fetchedStep = await client.api.getStep(stepId!);
91+
92+
expect(fetchedStep?.attachments?.length).toBe(1);
93+
expect(fetchedStep?.attachments![0].id).toEqual(attachment!.id);
94+
});
95+
});
96+
});

tests/chainlit-logo.png

-9.98 KB
Loading

0 commit comments

Comments
 (0)