Skip to content

Commit 6974b6e

Browse files
author
op-simoneromeo
authored
Implement Fireworks AI adapter (#353)
1 parent 8986078 commit 6974b6e

2 files changed

Lines changed: 198 additions & 6 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,129 @@
11
import { smokeTest } from '@profullstack/sh1pt-core/testing';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
23
import adapter from './index.js';
34

45
smokeTest(adapter, { idPrefix: 'ai' });
6+
7+
const ctx = (
8+
secrets: Record<string, string> = { FIREWORKS_API_KEY: 'test-key' },
9+
dryRun = false,
10+
) => ({
11+
secret: (key: string) => secrets[key],
12+
log: () => {},
13+
dryRun,
14+
});
15+
16+
describe('Fireworks AI generation', () => {
17+
afterEach(() => {
18+
vi.unstubAllGlobals();
19+
});
20+
21+
it('requires a Fireworks API key', async () => {
22+
await expect(adapter.generate(ctx({}, false), 'hello', {}, {})).rejects.toThrow(
23+
/FIREWORKS_API_KEY/,
24+
);
25+
});
26+
27+
it('short-circuits dry-run before network calls', async () => {
28+
const fetchMock = vi.fn();
29+
vi.stubGlobal('fetch', fetchMock);
30+
31+
const result = await adapter.generate(
32+
ctx({ FIREWORKS_API_KEY: 'test-key' }, true),
33+
'hello',
34+
{},
35+
{},
36+
);
37+
38+
expect(result).toEqual({
39+
text: '[dry-run]',
40+
model: 'accounts/fireworks/models/llama-v3p3-70b-instruct',
41+
});
42+
expect(fetchMock).not.toHaveBeenCalled();
43+
});
44+
45+
it('posts chat completions requests and maps usage tokens', async () => {
46+
const fetchMock = vi.fn().mockResolvedValue({
47+
ok: true,
48+
json: async () => ({
49+
model: 'accounts/fireworks/models/llama-v3p3-70b-instruct',
50+
choices: [{ message: { role: 'assistant', content: 'hi from fireworks' } }],
51+
usage: { prompt_tokens: 11, completion_tokens: 4, total_tokens: 15 },
52+
}),
53+
});
54+
vi.stubGlobal('fetch', fetchMock);
55+
56+
const result = await adapter.generate(
57+
ctx(),
58+
'hello',
59+
{
60+
system: 'be direct',
61+
maxTokens: 80,
62+
temperature: 0.5,
63+
extra: { top_p: 0.9, request_id: 'req-fireworks' },
64+
},
65+
{},
66+
);
67+
68+
expect(fetchMock).toHaveBeenCalledOnce();
69+
const call = fetchMock.mock.calls[0];
70+
expect(call).toBeDefined();
71+
const [url, request] = call!;
72+
expect(url).toBe('https://api.fireworks.ai/inference/v1/chat/completions');
73+
expect(request.headers.authorization).toBe('Bearer test-key');
74+
expect(request.headers['content-type']).toBe('application/json');
75+
expect(JSON.parse(request.body)).toEqual({
76+
model: 'accounts/fireworks/models/llama-v3p3-70b-instruct',
77+
messages: [
78+
{ role: 'system', content: 'be direct' },
79+
{ role: 'user', content: 'hello' },
80+
],
81+
stream: false,
82+
max_tokens: 80,
83+
temperature: 0.5,
84+
top_p: 0.9,
85+
request_id: 'req-fireworks',
86+
});
87+
expect(result).toEqual({
88+
text: 'hi from fireworks',
89+
model: 'accounts/fireworks/models/llama-v3p3-70b-instruct',
90+
inputTokens: 11,
91+
outputTokens: 4,
92+
});
93+
});
94+
95+
it('supports text-style choices and custom base URLs', async () => {
96+
const fetchMock = vi.fn().mockResolvedValue({
97+
ok: true,
98+
json: async () => ({
99+
choices: [{ text: 'legacy text response' }],
100+
}),
101+
});
102+
vi.stubGlobal('fetch', fetchMock);
103+
104+
const result = await adapter.generate(
105+
ctx(),
106+
'hello',
107+
{ model: 'accounts/fireworks/models/llama-v3p1-8b-instruct' },
108+
{ baseUrl: 'https://fireworks.test/inference/v1/' },
109+
);
110+
111+
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://fireworks.test/inference/v1/chat/completions');
112+
expect(result).toEqual({
113+
text: 'legacy text response',
114+
model: 'accounts/fireworks/models/llama-v3p1-8b-instruct',
115+
});
116+
});
117+
118+
it('includes status and redacted response body excerpts on errors', async () => {
119+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
120+
ok: false,
121+
status: 401,
122+
text: async () => 'invalid api key test-key',
123+
}));
124+
125+
await expect(adapter.generate(ctx(), 'hello', {}, {})).rejects.toThrow(
126+
/Fireworks AI 401: invalid api key \[redacted\]/,
127+
);
128+
});
129+
});

packages/ai/fireworks/src/index.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,94 @@ interface Config {
44
baseUrl?: string;
55
}
66

7+
const DEFAULT_BASE = 'https://api.fireworks.ai/inference/v1';
8+
const DEFAULT_MODEL = 'accounts/fireworks/models/llama-v3p3-70b-instruct';
9+
710
export default defineAi<Config>({
811
id: 'ai-fireworks',
912
label: 'Fireworks AI',
10-
defaultModel: 'accounts/fireworks/models/llama-v3p3-70b-instruct',
11-
models: ['accounts/fireworks/models/llama-v3p3-70b-instruct'],
13+
defaultModel: DEFAULT_MODEL,
14+
models: [
15+
DEFAULT_MODEL,
16+
'accounts/fireworks/models/llama-v3p1-70b-instruct',
17+
'accounts/fireworks/models/llama-v3p1-8b-instruct',
18+
],
1219

13-
async generate(ctx, prompt, _opts, _config) {
20+
async generate(ctx, prompt, opts, config) {
1421
const apiKey = ctx.secret('FIREWORKS_API_KEY');
1522
if (!apiKey) throw new Error('FIREWORKS_API_KEY not in vault — run `sh1pt promote ai setup`');
16-
ctx.log(`[stub] ai-fireworks · ${prompt.length} chars in — integration pending`);
17-
return { text: '[stub — ai-fireworks integration not yet implemented]', model: 'accounts/fireworks/models/llama-v3p3-70b-instruct' };
23+
const model = opts.model ?? DEFAULT_MODEL;
24+
ctx.log(`fireworks · model=${model} · ${prompt.length} chars in`);
25+
if (ctx.dryRun) return { text: '[dry-run]', model };
26+
27+
const messages: FireworksMessage[] = [];
28+
if (opts.system) messages.push({ role: 'system', content: opts.system });
29+
messages.push({ role: 'user', content: prompt });
30+
31+
const baseUrl = (config.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '');
32+
const res = await fetch(`${baseUrl}/chat/completions`, {
33+
method: 'POST',
34+
headers: {
35+
authorization: `Bearer ${apiKey}`,
36+
'content-type': 'application/json',
37+
},
38+
body: JSON.stringify({
39+
model,
40+
messages,
41+
stream: false,
42+
...(opts.maxTokens !== undefined ? { max_tokens: opts.maxTokens } : {}),
43+
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
44+
...opts.extra,
45+
}),
46+
});
47+
if (!res.ok) {
48+
const excerpt = redact((await res.text()).slice(0, 200), apiKey);
49+
throw new Error(`Fireworks AI ${res.status}: ${excerpt}`);
50+
}
51+
52+
const data = await res.json() as FireworksChatResponse;
53+
const choice = data.choices[0];
54+
return {
55+
text: choice?.message?.content ?? choice?.text ?? '',
56+
model: data.model ?? model,
57+
inputTokens: data.usage?.prompt_tokens,
58+
outputTokens: data.usage?.completion_tokens,
59+
};
1860
},
1961

2062
setup: tokenSetup<Config>({
2163
secretKey: 'FIREWORKS_API_KEY',
2264
label: 'Fireworks AI',
23-
vendorDocUrl: 'https://fireworks.ai',
65+
vendorDocUrl: 'https://docs.fireworks.ai/api-reference/post-chatcompletions',
2466
steps: [
2567
'Sign in at https://fireworks.ai and create an API key',
2668
'Copy the key — usually shown once',
2769
'Paste below; sh1pt encrypts it in the vault',
2870
],
2971
}),
3072
});
73+
74+
type FireworksRole = 'system' | 'user' | 'assistant' | 'tool';
75+
76+
interface FireworksMessage {
77+
role: FireworksRole;
78+
content: string;
79+
}
80+
81+
interface FireworksChatResponse {
82+
model?: string;
83+
choices: Array<{
84+
message?: {
85+
content?: string;
86+
};
87+
text?: string;
88+
}>;
89+
usage?: {
90+
prompt_tokens?: number;
91+
completion_tokens?: number;
92+
};
93+
}
94+
95+
function redact(text: string, secret: string): string {
96+
return secret ? text.split(secret).join('[redacted]') : text;
97+
}

0 commit comments

Comments
 (0)