Skip to content

Commit 60c7dc5

Browse files
authored
Implement Nebius chat completions adapter (#87)
1 parent 41f2a69 commit 60c7dc5

2 files changed

Lines changed: 163 additions & 8 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,100 @@
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 = (secrets: Record<string, string> = { NEBIUS_API_KEY: 'test-key' }, dryRun = false) => ({
8+
secret: (key: string) => secrets[key],
9+
log: () => {},
10+
dryRun,
11+
});
12+
13+
describe('Nebius Token Factory chat completions generation', () => {
14+
afterEach(() => {
15+
vi.unstubAllGlobals();
16+
});
17+
18+
it('short-circuits dry-run before network calls', async () => {
19+
const fetchMock = vi.fn();
20+
vi.stubGlobal('fetch', fetchMock);
21+
22+
const result = await adapter.generate(ctx({ NEBIUS_API_KEY: 'test-key' }, true), 'hello', {}, {});
23+
24+
expect(result).toEqual({ text: '[dry-run]', model: 'meta-llama/Llama-3.3-70B-Instruct' });
25+
expect(fetchMock).not.toHaveBeenCalled();
26+
});
27+
28+
it('posts chat completions requests and maps usage tokens', async () => {
29+
const fetchMock = vi.fn().mockResolvedValue({
30+
ok: true,
31+
json: async () => ({
32+
model: 'deepseek-ai/DeepSeek-R1-0528',
33+
choices: [{ message: { role: 'assistant', content: 'hi from nebius' } }],
34+
usage: { prompt_tokens: 13, completion_tokens: 5, total_tokens: 18 },
35+
}),
36+
});
37+
vi.stubGlobal('fetch', fetchMock);
38+
39+
const result = await adapter.generate(ctx(), 'hello', {
40+
model: 'deepseek-ai/DeepSeek-R1-0528',
41+
system: 'be brief',
42+
maxTokens: 28,
43+
temperature: 0.3,
44+
extra: { top_p: 0.8, service_tier: 'auto' },
45+
}, {});
46+
47+
expect(fetchMock).toHaveBeenCalledOnce();
48+
const call = fetchMock.mock.calls[0];
49+
expect(call).toBeDefined();
50+
const [url, request] = call!;
51+
expect(url).toBe('https://api.tokenfactory.nebius.com/v1/chat/completions');
52+
expect(request.headers.authorization).toBe('Bearer test-key');
53+
expect(request.headers['content-type']).toBe('application/json');
54+
expect(JSON.parse(request.body)).toEqual({
55+
stream: false,
56+
model: 'deepseek-ai/DeepSeek-R1-0528',
57+
messages: [
58+
{ role: 'system', content: 'be brief' },
59+
{ role: 'user', content: 'hello' },
60+
],
61+
max_tokens: 28,
62+
temperature: 0.3,
63+
top_p: 0.8,
64+
service_tier: 'auto',
65+
});
66+
expect(result).toEqual({
67+
text: 'hi from nebius',
68+
model: 'deepseek-ai/DeepSeek-R1-0528',
69+
inputTokens: 13,
70+
outputTokens: 5,
71+
});
72+
});
73+
74+
it('supports text-style choices from compatible Nebius responses', async () => {
75+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
76+
ok: true,
77+
json: async () => ({
78+
model: 'meta-llama/Llama-3.3-70B-Instruct',
79+
choices: [{ text: 'text choice response' }],
80+
}),
81+
}));
82+
83+
const result = await adapter.generate(ctx(), 'hello', {}, { baseUrl: 'https://nebius.test' });
84+
85+
expect(result).toEqual({
86+
text: 'text choice response',
87+
model: 'meta-llama/Llama-3.3-70B-Instruct',
88+
});
89+
});
90+
91+
it('includes status and response body excerpt on errors', async () => {
92+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
93+
ok: false,
94+
status: 422,
95+
text: async () => 'invalid request'.repeat(30),
96+
}));
97+
98+
await expect(adapter.generate(ctx(), 'hello', {}, {})).rejects.toThrow(/Nebius 422: invalid request/);
99+
});
100+
});

packages/ai/nebius/src/index.ts

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

7+
const DEFAULT_BASE = 'https://api.tokenfactory.nebius.com';
8+
const DEFAULT_MODEL = 'meta-llama/Llama-3.3-70B-Instruct';
9+
710
export default defineAi<Config>({
811
id: 'ai-nebius',
912
label: 'Nebius Token Factory',
10-
defaultModel: 'meta-llama/Llama-3.3-70B-Instruct',
11-
models: ['meta-llama/Llama-3.3-70B-Instruct'],
13+
defaultModel: DEFAULT_MODEL,
14+
models: [
15+
DEFAULT_MODEL,
16+
'meta-llama/Meta-Llama-3.1-70B-Instruct',
17+
'deepseek-ai/DeepSeek-R1-0528',
18+
],
1219

13-
async generate(ctx, prompt, _opts, _config) {
20+
async generate(ctx, prompt, opts, config) {
1421
const apiKey = ctx.secret('NEBIUS_API_KEY');
15-
if (!apiKey) throw new Error('NEBIUS_API_KEY not in vault — run `sh1pt promote ai setup`');
16-
ctx.log(`[stub] ai-nebius · ${prompt.length} chars in — integration pending`);
17-
return { text: '[stub — ai-nebius integration not yet implemented]', model: 'meta-llama/Llama-3.3-70B-Instruct' };
22+
if (!apiKey) throw new Error('NEBIUS_API_KEY not in vault');
23+
const model = opts.model ?? DEFAULT_MODEL;
24+
ctx.log(`nebius · model=${model} · ${prompt.length} chars in`);
25+
if (ctx.dryRun) return { text: '[dry-run]', model };
26+
27+
const messages: NebiusMessage[] = [];
28+
if (opts.system) messages.push({ role: 'system', content: opts.system });
29+
messages.push({ role: 'user', content: prompt });
30+
31+
const res = await fetch(`${config.baseUrl ?? DEFAULT_BASE}/v1/chat/completions`, {
32+
method: 'POST',
33+
headers: {
34+
authorization: `Bearer ${apiKey}`,
35+
'content-type': 'application/json',
36+
},
37+
body: JSON.stringify({
38+
stream: false,
39+
model,
40+
messages,
41+
...(opts.maxTokens !== undefined ? { max_tokens: opts.maxTokens } : {}),
42+
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
43+
...opts.extra,
44+
}),
45+
});
46+
if (!res.ok) throw new Error(`Nebius ${res.status}: ${(await res.text()).slice(0, 200)}`);
47+
48+
const data = await res.json() as NebiusChatResponse;
49+
const choice = data.choices[0];
50+
return {
51+
text: choice?.message?.content ?? choice?.text ?? '',
52+
model: data.model,
53+
inputTokens: data.usage?.prompt_tokens,
54+
outputTokens: data.usage?.completion_tokens,
55+
};
1856
},
1957

2058
setup: tokenSetup<Config>({
2159
secretKey: 'NEBIUS_API_KEY',
2260
label: 'Nebius Token Factory',
23-
vendorDocUrl: 'https://studio.nebius.ai',
61+
vendorDocUrl: 'https://docs.tokenfactory.nebius.com/api-reference/inference/create-chat-completion',
2462
steps: [
25-
'Sign in at https://studio.nebius.ai and create an API key',
63+
'Sign in at https://tokenfactory.nebius.com and create an API key',
2664
'Copy the key — usually shown once',
2765
'Paste below; sh1pt encrypts it in the vault',
2866
],
2967
}),
3068
});
69+
70+
type NebiusRole = 'system' | 'user' | 'assistant' | 'tool';
71+
72+
interface NebiusMessage {
73+
role: NebiusRole;
74+
content: string;
75+
}
76+
77+
interface NebiusChatResponse {
78+
model: string;
79+
choices: Array<{
80+
message?: {
81+
content?: string;
82+
};
83+
text?: string;
84+
}>;
85+
usage?: {
86+
prompt_tokens?: number;
87+
completion_tokens?: number;
88+
};
89+
}

0 commit comments

Comments
 (0)