Skip to content

Commit c09537a

Browse files
committed
feat: replace VercelProvider with Runner protocol implementation (AIC-2388) (#1339)
1 parent 663c7cf commit c09537a

9 files changed

Lines changed: 683 additions & 1218 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
convertMessagesToVercel,
3+
getAIMetricsFromResponse,
4+
getAIMetricsFromStream,
5+
mapProviderName,
6+
mapUsageDataToLDTokenUsage,
7+
} from '../src/VercelHelper';
8+
9+
describe('convertMessagesToVercel', () => {
10+
it('passes role and content through unchanged', () => {
11+
expect(
12+
convertMessagesToVercel([
13+
{ role: 'system', content: 'sys' },
14+
{ role: 'user', content: 'u' },
15+
{ role: 'assistant', content: 'a' },
16+
]),
17+
).toEqual([
18+
{ role: 'system', content: 'sys' },
19+
{ role: 'user', content: 'u' },
20+
{ role: 'assistant', content: 'a' },
21+
]);
22+
});
23+
});
24+
25+
describe('mapProviderName', () => {
26+
it('maps gemini to google (case-insensitive)', () => {
27+
expect(mapProviderName('gemini')).toBe('google');
28+
expect(mapProviderName('Gemini')).toBe('google');
29+
});
30+
31+
it('returns the provider unchanged when no mapping exists', () => {
32+
expect(mapProviderName('openai')).toBe('openai');
33+
expect(mapProviderName('anthropic')).toBe('anthropic');
34+
});
35+
});
36+
37+
describe('mapUsageDataToLDTokenUsage', () => {
38+
it('prefers v5 field names (inputTokens / outputTokens) over v4', () => {
39+
const usage = mapUsageDataToLDTokenUsage({
40+
totalTokens: 100,
41+
inputTokens: 40,
42+
outputTokens: 60,
43+
promptTokens: 1,
44+
completionTokens: 2,
45+
});
46+
expect(usage).toEqual({ total: 100, input: 40, output: 60 });
47+
});
48+
49+
it('falls back to v4 field names when v5 is absent', () => {
50+
const usage = mapUsageDataToLDTokenUsage({
51+
totalTokens: 50,
52+
promptTokens: 20,
53+
completionTokens: 30,
54+
});
55+
expect(usage).toEqual({ total: 50, input: 20, output: 30 });
56+
});
57+
});
58+
59+
describe('getAIMetricsFromResponse', () => {
60+
it('treats missing finishReason as success', () => {
61+
expect(
62+
getAIMetricsFromResponse({
63+
usage: { totalTokens: 5, promptTokens: 2, completionTokens: 3 },
64+
}),
65+
).toEqual({ success: true, usage: { total: 5, input: 2, output: 3 } });
66+
});
67+
68+
it('marks success=false when finishReason is "error"', () => {
69+
expect(
70+
getAIMetricsFromResponse({
71+
finishReason: 'error',
72+
usage: { totalTokens: 10, promptTokens: 4, completionTokens: 6 },
73+
}).success,
74+
).toBe(false);
75+
});
76+
});
77+
78+
describe('getAIMetricsFromStream', () => {
79+
it('extracts usage from a successful stream', async () => {
80+
const result = await getAIMetricsFromStream({
81+
finishReason: Promise.resolve('stop'),
82+
usage: Promise.resolve({ totalTokens: 100, promptTokens: 49, completionTokens: 51 }),
83+
});
84+
expect(result).toEqual({
85+
success: true,
86+
usage: { total: 100, input: 49, output: 51 },
87+
});
88+
});
89+
90+
it('marks success=false on error finishReason', async () => {
91+
const result = await getAIMetricsFromStream({
92+
finishReason: Promise.resolve('error'),
93+
});
94+
expect(result.success).toBe(false);
95+
});
96+
});
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { generateObject, generateText, jsonSchema } from 'ai';
2+
3+
import type { LDAICompletionConfig } from '@launchdarkly/server-sdk-ai';
4+
5+
import { VercelModelRunner } from '../src/VercelModelRunner';
6+
7+
jest.mock('ai', () => ({
8+
generateText: jest.fn(),
9+
generateObject: jest.fn(),
10+
jsonSchema: jest.fn((schema) => schema),
11+
}));
12+
13+
const mockLogger = {
14+
warn: jest.fn(),
15+
info: jest.fn(),
16+
error: jest.fn(),
17+
debug: jest.fn(),
18+
};
19+
20+
const baseConfig: LDAICompletionConfig = {
21+
key: 'completion',
22+
enabled: true,
23+
model: { name: 'mock' },
24+
};
25+
26+
describe('VercelModelRunner', () => {
27+
const fakeModel = { name: 'mock' };
28+
let runner: VercelModelRunner;
29+
30+
beforeEach(() => {
31+
runner = new VercelModelRunner(fakeModel as any, baseConfig, {}, mockLogger);
32+
jest.clearAllMocks();
33+
});
34+
35+
describe('run (chat completion)', () => {
36+
it('returns a successful RunnerResult with content, metrics, and raw response', async () => {
37+
const result = {
38+
text: 'Hi!',
39+
usage: { totalTokens: 12, promptTokens: 7, completionTokens: 5 },
40+
};
41+
(generateText as jest.Mock).mockResolvedValue(result);
42+
43+
const out = await runner.run('hello');
44+
45+
expect(generateText).toHaveBeenCalledWith({
46+
model: fakeModel,
47+
messages: [{ role: 'user', content: 'hello' }],
48+
experimental_telemetry: { isEnabled: true },
49+
});
50+
expect(out.content).toBe('Hi!');
51+
expect(out.metrics).toEqual({
52+
success: true,
53+
usage: { total: 12, input: 7, output: 5 },
54+
});
55+
expect(out.raw).toBe(result);
56+
});
57+
58+
it('prepends config messages before the user prompt', async () => {
59+
(generateText as jest.Mock).mockResolvedValue({
60+
text: 'reply',
61+
usage: { totalTokens: 1, promptTokens: 1, completionTokens: 0 },
62+
});
63+
64+
const configWithMessages: LDAICompletionConfig = {
65+
...baseConfig,
66+
messages: [{ role: 'system', content: 'You are X' }],
67+
};
68+
const r = new VercelModelRunner(fakeModel as any, configWithMessages, {}, mockLogger);
69+
await r.run('hi');
70+
71+
expect(generateText).toHaveBeenCalledWith({
72+
model: fakeModel,
73+
messages: [
74+
{ role: 'system', content: 'You are X' },
75+
{ role: 'user', content: 'hi' },
76+
],
77+
experimental_telemetry: { isEnabled: true },
78+
});
79+
});
80+
81+
it('preserves v5 token field handling via getAIMetricsFromResponse', async () => {
82+
(generateText as jest.Mock).mockResolvedValue({
83+
text: 'ok',
84+
usage: { totalTokens: 100, inputTokens: 40, outputTokens: 60 },
85+
});
86+
87+
const out = await runner.run('hello');
88+
89+
expect(out.metrics.usage).toEqual({ total: 100, input: 40, output: 60 });
90+
});
91+
92+
it('uses a LDMessage[] directly without prepending config messages', async () => {
93+
(generateText as jest.Mock).mockResolvedValue({
94+
text: 'direct',
95+
usage: { totalTokens: 5, promptTokens: 2, completionTokens: 3 },
96+
});
97+
98+
const configWithMessages: LDAICompletionConfig = {
99+
...baseConfig,
100+
messages: [{ role: 'system', content: 'Should not appear' }],
101+
};
102+
const r = new VercelModelRunner(fakeModel as any, configWithMessages, {}, mockLogger);
103+
const prebuilt = [
104+
{ role: 'system' as const, content: 'Custom system' },
105+
{ role: 'user' as const, content: 'Direct input' },
106+
];
107+
await r.run(prebuilt);
108+
109+
expect(generateText).toHaveBeenCalledWith({
110+
model: fakeModel,
111+
messages: prebuilt,
112+
experimental_telemetry: { isEnabled: true },
113+
});
114+
});
115+
116+
it('returns success=false when generateText throws', async () => {
117+
const err = new Error('boom');
118+
(generateText as jest.Mock).mockRejectedValue(err);
119+
120+
const out = await runner.run('hello');
121+
122+
expect(out.content).toBe('');
123+
expect(out.metrics.success).toBe(false);
124+
expect(mockLogger.warn).toHaveBeenCalledWith('Vercel AI model invocation failed:', err);
125+
});
126+
});
127+
128+
describe('run (structured output)', () => {
129+
it('exposes parsed structured output via parsed', async () => {
130+
const obj = { name: 'Ada', age: 36 };
131+
(generateObject as jest.Mock).mockResolvedValue({
132+
object: obj,
133+
usage: { totalTokens: 30, promptTokens: 10, completionTokens: 20 },
134+
});
135+
136+
const schema = { type: 'object' };
137+
const out = await runner.run('tell', schema);
138+
139+
expect(jsonSchema).toHaveBeenCalledWith(schema);
140+
expect(generateObject).toHaveBeenCalledWith({
141+
model: fakeModel,
142+
messages: [{ role: 'user', content: 'tell' }],
143+
schema,
144+
experimental_telemetry: { isEnabled: true },
145+
});
146+
expect(out.parsed).toEqual(obj);
147+
expect(out.content).toBe(JSON.stringify(obj));
148+
expect(out.metrics.success).toBe(true);
149+
});
150+
151+
it('returns success=false when generateObject throws', async () => {
152+
const err = new Error('struct boom');
153+
(generateObject as jest.Mock).mockRejectedValue(err);
154+
155+
const out = await runner.run('tell', { type: 'object' });
156+
157+
expect(out.content).toBe('');
158+
expect(out.parsed).toBeUndefined();
159+
expect(out.metrics.success).toBe(false);
160+
expect(mockLogger.warn).toHaveBeenCalledWith(
161+
'Vercel AI structured model invocation failed:',
162+
err,
163+
);
164+
});
165+
});
166+
167+
describe('getModel', () => {
168+
it('returns the underlying Vercel AI model', () => {
169+
expect(runner.getModel()).toBe(fakeModel);
170+
});
171+
});
172+
});

0 commit comments

Comments
 (0)