Skip to content

Commit a21cb26

Browse files
authored
Implement AtlasCloud AI adapter (#766)
Replace the placeholder stub (which had a URL as its secretKey and the key name as its model ID) with a working OpenAI-compatible chat completion client, following the groq adapter's pattern. Model list pulled from the live unauthenticated GET /v1/models response rather than guessed.
1 parent 64bb906 commit a21cb26

2 files changed

Lines changed: 167 additions & 11 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,109 @@
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> = { ATLASCLOUD_API_KEY: 'test-key' }, dryRun = false) => ({
8+
secret: (key: string) => secrets[key],
9+
log: () => {},
10+
dryRun,
11+
});
12+
13+
describe('AtlasCloud OpenAI-compatible 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({ ATLASCLOUD_API_KEY: 'test-key' }, true), 'hello', {}, {});
23+
24+
expect(result).toEqual({ text: '[dry-run]', model: 'deepseek-ai/DeepSeek-V3-0324' });
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+
choices: [{ message: { content: 'hi from atlascloud' } }],
33+
model: 'qwen/qwen3-32b',
34+
usage: { prompt_tokens: 7, completion_tokens: 3 },
35+
}),
36+
});
37+
vi.stubGlobal('fetch', fetchMock);
38+
39+
const result = await adapter.generate(ctx(), 'hello', {
40+
model: 'qwen/qwen3-32b',
41+
system: 'be brief',
42+
maxTokens: 20,
43+
temperature: 0.2,
44+
extra: { top_p: 0.9 },
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.atlascloud.ai/v1/chat/completions');
52+
expect(request.headers.authorization).toBe('Bearer test-key');
53+
expect(JSON.parse(request.body)).toEqual({
54+
model: 'qwen/qwen3-32b',
55+
messages: [
56+
{ role: 'system', content: 'be brief' },
57+
{ role: 'user', content: 'hello' },
58+
],
59+
max_tokens: 20,
60+
temperature: 0.2,
61+
top_p: 0.9,
62+
});
63+
expect(result).toEqual({
64+
text: 'hi from atlascloud',
65+
model: 'qwen/qwen3-32b',
66+
inputTokens: 7,
67+
outputTokens: 3,
68+
});
69+
});
70+
71+
it('normalizes configured base URLs with trailing slashes', async () => {
72+
const fetchMock = vi.fn().mockResolvedValue({
73+
ok: true,
74+
json: async () => ({ choices: [{ message: { content: 'ok' } }], model: 'deepseek-ai/DeepSeek-V3-0324' }),
75+
});
76+
vi.stubGlobal('fetch', fetchMock);
77+
78+
await adapter.generate(ctx(), 'hello', {}, { baseUrl: 'https://proxy.example.com/' });
79+
80+
const [url] = fetchMock.mock.calls[0]!;
81+
expect(url).toBe('https://proxy.example.com/v1/chat/completions');
82+
});
83+
84+
it('includes status and redacted response body excerpt on errors', async () => {
85+
const apiKey = 'test-key-crossing-truncation-boundary';
86+
const prefix = 'x'.repeat(190);
87+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
88+
ok: false,
89+
status: 401,
90+
text: async () => `${prefix}${apiKey} invalid token`,
91+
}));
92+
93+
let error: unknown;
94+
try {
95+
await adapter.generate(ctx({ ATLASCLOUD_API_KEY: apiKey }), 'hello', {}, {});
96+
} catch (exc) {
97+
error = exc;
98+
}
99+
expect(error).toBeInstanceOf(Error);
100+
expect((error as Error).message).toContain('AtlasCloud 401:');
101+
expect((error as Error).message).toContain('[redacted]');
102+
expect((error as Error).message).not.toContain(apiKey);
103+
expect((error as Error).message).not.toContain(apiKey.slice(0, 10));
104+
});
105+
106+
it('throws when the API key is missing from the vault', async () => {
107+
await expect(adapter.generate(ctx({}), 'hello', {}, {})).rejects.toThrow('ATLASCLOUD_API_KEY not in vault');
108+
});
109+
});

packages/ai/atlascloud/src/index.ts

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,76 @@ interface Config {
44
baseUrl?: string;
55
}
66

7+
const DEFAULT_BASE = 'https://api.atlascloud.ai';
8+
9+
function chatCompletionsUrl(baseUrl?: string): string {
10+
return `${(baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '')}/v1/chat/completions`;
11+
}
12+
13+
function redact(value: string, apiKey: string): string {
14+
return apiKey ? value.split(apiKey).join('[redacted]') : value;
15+
}
16+
717
export default defineAi<Config>({
818
id: 'ai-atlascloud',
919
label: 'AtlasCloud',
10-
defaultModel: 'ATLASCLOUD_API_KEY',
11-
models: ['ATLASCLOUD_API_KEY'],
12-
13-
async generate(ctx, prompt, _opts, _config) {
14-
const apiKey = ctx.secret('https://atlascloud.ai');
15-
if (!apiKey) throw new Error('https://atlascloud.ai not in vault — run `sh1pt promote ai setup`');
16-
ctx.log(`[stub] ai-atlascloud · ${prompt.length} chars in — integration pending`);
17-
return { text: '[stub — ai-atlascloud integration not yet implemented]', model: 'ATLASCLOUD_API_KEY' };
20+
defaultModel: 'deepseek-ai/DeepSeek-V3-0324',
21+
models: [
22+
'deepseek-ai/DeepSeek-V3-0324',
23+
'deepseek-ai/deepseek-r1-0528',
24+
'deepseek-ai/DeepSeek-V3.1',
25+
'qwen/qwen3-32b',
26+
'Qwen/Qwen3-Coder',
27+
'Qwen/Qwen3-235B-A22B-Instruct-2507',
28+
'moonshotai/Kimi-K2-Instruct',
29+
'moonshotai/Kimi-K2-Instruct-0905',
30+
],
31+
32+
async generate(ctx, prompt, opts, config) {
33+
const apiKey = ctx.secret('ATLASCLOUD_API_KEY');
34+
if (!apiKey) throw new Error('ATLASCLOUD_API_KEY not in vault');
35+
const model = opts.model ?? 'deepseek-ai/DeepSeek-V3-0324';
36+
ctx.log(`atlascloud · model=${model} · ${prompt.length} chars in`);
37+
if (ctx.dryRun) return { text: '[dry-run]', model };
38+
39+
const messages: Array<{ role: string; content: string }> = [];
40+
if (opts.system) messages.push({ role: 'system', content: opts.system });
41+
messages.push({ role: 'user', content: prompt });
42+
43+
const res = await fetch(chatCompletionsUrl(config.baseUrl), {
44+
method: 'POST',
45+
headers: {
46+
authorization: `Bearer ${apiKey}`,
47+
'content-type': 'application/json',
48+
},
49+
body: JSON.stringify({
50+
model,
51+
messages,
52+
...(opts.maxTokens !== undefined ? { max_tokens: opts.maxTokens } : {}),
53+
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
54+
...opts.extra,
55+
}),
56+
});
57+
if (!res.ok) throw new Error(`AtlasCloud ${res.status}: ${redact(await res.text(), apiKey).slice(0, 200)}`);
58+
const data = (await res.json()) as {
59+
choices: Array<{ message?: { content?: string } }>;
60+
model: string;
61+
usage?: { prompt_tokens?: number; completion_tokens?: number };
62+
};
63+
return {
64+
text: data.choices[0]?.message?.content ?? '',
65+
model: data.model,
66+
inputTokens: data.usage?.prompt_tokens,
67+
outputTokens: data.usage?.completion_tokens,
68+
};
1869
},
1970

2071
setup: tokenSetup<Config>({
21-
secretKey: 'https://atlascloud.ai',
72+
secretKey: 'ATLASCLOUD_API_KEY',
2273
label: 'AtlasCloud',
23-
vendorDocUrl: '',
74+
vendorDocUrl: 'https://docs.atlascloud.ai',
2475
steps: [
25-
'Sign in at and create an API key',
76+
'Sign in at https://www.atlascloud.ai and create an API key',
2677
'Copy the key — usually shown once',
2778
'Paste below; sh1pt encrypts it in the vault',
2879
],

0 commit comments

Comments
 (0)