Skip to content

Commit c620a8a

Browse files
authored
Merge branch 'master' into fix/link-checker-tab-visibility-reload-es-524
2 parents 1d06949 + 7ba8a98 commit c620a8a

207 files changed

Lines changed: 18089 additions & 10058 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/ai-content-generator/.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@contentful:registry=https://registry.npmjs.org/
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"functions": [
3+
{
4+
"id": "openaiProxyFunction",
5+
"name": "OpenAI proxy function",
6+
"description": "Calls OpenAI chat completions using the secret API key stored server-side.",
7+
"path": "functions/openai-proxy.js",
8+
"entryFile": "functions/openai-proxy.ts",
9+
"allowNetworks": [
10+
"https://api.openai.com"
11+
],
12+
"accepts": [
13+
"appaction.call"
14+
]
15+
}
16+
],
17+
"actions": [
18+
{
19+
"id": "openaiProxyAction",
20+
"name": "OpenAI proxy action",
21+
"type": "function-invocation",
22+
"functionId": "openaiProxyFunction",
23+
"category": "Custom",
24+
"parameters": [
25+
{
26+
"id": "messages",
27+
"name": "Messages",
28+
"type": "Symbol",
29+
"required": true
30+
},
31+
{
32+
"id": "model",
33+
"name": "Model",
34+
"type": "Symbol",
35+
"required": true
36+
}
37+
]
38+
}
39+
]
40+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { handler, OpenAiProxyParameters } from './openai-proxy';
3+
4+
type HandlerEvent = Parameters<typeof handler>[0];
5+
type HandlerContext = Parameters<typeof handler>[1];
6+
7+
// The proxy only reads `key` off the installation params and `messages`/`model`
8+
// off the action body — build the smallest shapes the handler actually touches.
9+
const makeEvent = (body: OpenAiProxyParameters): HandlerEvent =>
10+
({ body } as unknown as HandlerEvent);
11+
12+
const makeContext = (key?: string): HandlerContext =>
13+
({ appInstallationParameters: key ? { key } : {} } as unknown as HandlerContext);
14+
15+
const validBody: OpenAiProxyParameters = {
16+
messages: JSON.stringify([{ role: 'user', content: 'hi' }]),
17+
model: 'gpt-4',
18+
};
19+
20+
describe('openai-proxy handler', () => {
21+
beforeEach(() => {
22+
vi.stubGlobal('fetch', vi.fn());
23+
});
24+
25+
afterEach(() => {
26+
vi.unstubAllGlobals();
27+
vi.restoreAllMocks();
28+
});
29+
30+
it('throws when no API key is configured', async () => {
31+
await expect(handler(makeEvent(validBody), makeContext())).rejects.toThrow(
32+
'OpenAI API key is not configured'
33+
);
34+
expect(fetch).not.toHaveBeenCalled();
35+
});
36+
37+
it('throws when messages is not valid JSON', async () => {
38+
const event = makeEvent({ messages: 'not json', model: 'gpt-4' });
39+
40+
await expect(handler(event, makeContext('sk-test'))).rejects.toThrow(
41+
'Invalid messages parameter: must be a JSON-encoded array'
42+
);
43+
expect(fetch).not.toHaveBeenCalled();
44+
});
45+
46+
it('calls OpenAI with the key and parsed messages, returning the completion text', async () => {
47+
vi.mocked(fetch).mockResolvedValue({
48+
ok: true,
49+
json: async () => ({ choices: [{ message: { content: 'generated' } }] }),
50+
} as Response);
51+
52+
const result = await handler(makeEvent(validBody), makeContext('sk-test'));
53+
54+
expect(result).toEqual({ text: 'generated' });
55+
expect(fetch).toHaveBeenCalledWith(
56+
'https://api.openai.com/v1/chat/completions',
57+
expect.objectContaining({
58+
method: 'POST',
59+
headers: expect.objectContaining({ Authorization: 'Bearer sk-test' }),
60+
body: JSON.stringify({
61+
model: 'gpt-4',
62+
messages: [{ role: 'user', content: 'hi' }],
63+
}),
64+
})
65+
);
66+
});
67+
68+
it('returns empty text when the completion has no content', async () => {
69+
vi.mocked(fetch).mockResolvedValue({
70+
ok: true,
71+
json: async () => ({ choices: [] }),
72+
} as Response);
73+
74+
const result = await handler(makeEvent(validBody), makeContext('sk-test'));
75+
76+
expect(result).toEqual({ text: '' });
77+
});
78+
79+
it('surfaces the OpenAI error message on a non-ok response', async () => {
80+
vi.mocked(fetch).mockResolvedValue({
81+
ok: false,
82+
status: 401,
83+
statusText: 'Unauthorized',
84+
json: async () => ({ error: { message: 'Invalid API key' } }),
85+
} as Response);
86+
87+
await expect(handler(makeEvent(validBody), makeContext('sk-test'))).rejects.toThrow(
88+
'OpenAI request failed: 401 Invalid API key'
89+
);
90+
});
91+
92+
it('falls back to statusText when the error body has no message', async () => {
93+
vi.mocked(fetch).mockResolvedValue({
94+
ok: false,
95+
status: 500,
96+
statusText: 'Internal Server Error',
97+
json: async () => {
98+
throw new Error('not json');
99+
},
100+
} as unknown as Response);
101+
102+
await expect(handler(makeEvent(validBody), makeContext('sk-test'))).rejects.toThrow(
103+
'OpenAI request failed: 500 Internal Server Error'
104+
);
105+
});
106+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type {
2+
FunctionEventHandler,
3+
FunctionTypeEnum,
4+
AppActionRequest,
5+
FunctionEventContext,
6+
} from '@contentful/node-apps-toolkit';
7+
8+
export type OpenAiProxyParameters = {
9+
messages: string;
10+
model: string;
11+
};
12+
13+
type OpenAiProxyResponse = {
14+
text: string;
15+
};
16+
17+
// Installation parameters are persisted as a flat scalar object (see the app's
18+
// PersistedInstallationParameters). The proxy only needs the Secret `key`.
19+
type InstallationParameters = {
20+
key?: string;
21+
};
22+
23+
export const handler: FunctionEventHandler<
24+
FunctionTypeEnum.AppActionCall,
25+
OpenAiProxyParameters
26+
> = async (
27+
event: AppActionRequest<'Custom', OpenAiProxyParameters>,
28+
context: FunctionEventContext
29+
): Promise<OpenAiProxyResponse> => {
30+
const { key } = context.appInstallationParameters as InstallationParameters;
31+
32+
if (!key) {
33+
throw new Error('OpenAI API key is not configured');
34+
}
35+
36+
const { messages, model } = event.body;
37+
38+
let parsedMessages: unknown;
39+
try {
40+
parsedMessages = JSON.parse(messages);
41+
} catch {
42+
throw new Error('Invalid messages parameter: must be a JSON-encoded array');
43+
}
44+
45+
const response = await fetch('https://api.openai.com/v1/chat/completions', {
46+
method: 'POST',
47+
headers: {
48+
Authorization: `Bearer ${key}`,
49+
'Content-Type': 'application/json',
50+
},
51+
body: JSON.stringify({
52+
model,
53+
messages: parsedMessages,
54+
}),
55+
});
56+
57+
if (!response.ok) {
58+
const errorBody = await response.json().catch(() => ({}));
59+
throw new Error(
60+
`OpenAI request failed: ${response.status} ${
61+
errorBody?.error?.message ?? response.statusText
62+
}`
63+
);
64+
}
65+
66+
const data = await response.json();
67+
const text: string = data.choices?.[0]?.message?.content ?? '';
68+
69+
return { text };
70+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"extends": "@tsconfig/recommended/tsconfig.json",
3+
"compilerOptions": {},
4+
"include": ["./**/*.ts"]
5+
}

0 commit comments

Comments
 (0)