Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AIMessage } from '@langchain/core/messages';

import type { LDAICompletionConfig } from '@launchdarkly/server-sdk-ai';
import type { LDAICompletionConfig, LDMessage } from '@launchdarkly/server-sdk-ai';

import { LangChainModelRunner } from '../src/LangChainModelRunner';

Expand Down Expand Up @@ -63,6 +63,25 @@ describe('LangChainModelRunner', () => {
expect(passed[1].content).toBe('hi');
});

it('uses a LDMessage[] as-is without prepending config messages', async () => {
const response = new AIMessage('direct reply');
mockLLM.invoke.mockResolvedValue(response);

const configWithMessages: LDAICompletionConfig = {
...baseConfig,
messages: [{ role: 'system', content: 'You are X' }],
};
const r = new LangChainModelRunner(mockLLM, configWithMessages, mockLogger);
const inputMessages: LDMessage[] = [
{ role: 'user', content: 'direct question' },
];
await r.run(inputMessages);

const passed = mockLLM.invoke.mock.calls[0][0];
expect(passed).toHaveLength(1);
expect(passed[0].content).toBe('direct question');
});

it('marks success=false and warns when content is non-string (multimodal)', async () => {
mockLLM.invoke.mockResolvedValue(new AIMessage([{ type: 'image' }] as any));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AIMessage, BaseMessage, HumanMessage } from '@langchain/core/messages';
import type {
LDAICompletionConfig,
LDLogger,
LDMessage,
Runner,
RunnerResult,
} from '@launchdarkly/server-sdk-ai';
Expand Down Expand Up @@ -38,25 +39,35 @@ export class LangChainModelRunner implements Runner {
}

/**
* Run the LangChain model with the given user prompt.
* Run the LangChain model with the given user prompt or message array.
*
* The runner maintains a LangChain `InMemoryChatMessageHistory` that is
* initialized from any messages on the AI config (system prompt, etc.). On
* every invocation the user prompt is appended to the existing history
* before being sent to the model. When `multiTurn` is `true` (the default)
* and the call succeeds with non-empty content, the user prompt and the
* assistant's reply are persisted to the history so subsequent calls
* continue the conversation. When `multiTurn` is `false`, history is
* treated as read-only — useful for stateless runners (e.g. judges) where
* every call should see only the initial config messages plus the current
* input. Failed calls leave the history unchanged so the next call can
* retry cleanly.
* When `input` is a string, the runner maintains a LangChain
* `InMemoryChatMessageHistory` initialized from any messages on the AI
* config. The user prompt is appended to the existing history before being
* sent to the model. When `multiTurn` is `true` (the default) and the call
* succeeds with non-empty content, the user prompt and the assistant's reply
* are persisted to the history so subsequent calls continue the
* conversation. When `multiTurn` is `false`, history is treated as
* read-only — useful for stateless runners (e.g. judges) where every call
* should see only the initial config messages plus the current input.
* Failed calls leave the history unchanged so the next call can retry
* cleanly.
*
* @param input The user prompt string.
* When `input` is a pre-built `LDMessage[]` it is used as-is — config
* messages are not prepended and history is not updated.
*
* @param input The user prompt string, or a pre-built message array.
* @param outputType Optional JSON schema for structured output. When provided,
* the parsed result is exposed via {@link RunnerResult.parsed}.
*/
async run(input: string, outputType?: Record<string, unknown>): Promise<RunnerResult> {
async run(input: string | LDMessage[], outputType?: Record<string, unknown>): Promise<RunnerResult> {
if (Array.isArray(input)) {
const langchainMessages = convertMessagesToLangChain(input);
return outputType !== undefined
? this._runStructured(langchainMessages, outputType)
: this._runCompletion(langchainMessages);
}

const langchainMessages: BaseMessage[] = [
...(await this._chatHistory.getMessages()),
new HumanMessage(input),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,27 @@ describe('OpenAIModelRunner', () => {
});
});

it('passes a LDMessage[] input directly without prepending config messages', async () => {
const mockResponse = {
choices: [{ message: { content: 'Evaluation result' } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
};
(mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse as any);

const messages = [
{ role: 'system' as const, content: 'You are a judge' },
{ role: 'user' as const, content: 'Rate this: hello' },
];
const result = await runner.run(messages);

expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({
model: 'gpt-3.5-turbo',
messages,
});
expect(result.content).toBe('Evaluation result');
expect(result.metrics.success).toBe(true);
});

it('marks the result unsuccessful when response has no content', async () => {
const mockResponse = { choices: [{ message: {} }] };
(mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse as any);
Expand Down
36 changes: 22 additions & 14 deletions packages/ai-providers/server-ai-openai/src/OpenAIModelRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,25 +39,33 @@ export class OpenAIModelRunner implements Runner {
}

/**
* Run the OpenAI model with the given user prompt.
* Run the OpenAI model with the given user prompt or message array.
*
* The runner maintains a conversation history that is initialized from any
* messages on the AI config (system prompt, instructions, etc.). On every
* invocation the user prompt is appended to the existing history before
* being sent to the model. When `multiTurn` is `true` (the default) and the
* call succeeds with non-empty content, the user prompt and the assistant's
* reply are persisted to the history so subsequent calls continue the
* conversation. When `multiTurn` is `false`, history is treated as
* read-only — useful for stateless runners (e.g. judges) where every call
* should see only the initial config messages plus the current input.
* Failed calls leave the history unchanged so the next call can retry
* cleanly.
* When `input` is a string, the runner maintains a conversation history
* initialized from any messages on the AI config. The user prompt is
* appended to the existing history before being sent to the model. When
* `multiTurn` is `true` (the default) and the call succeeds with non-empty
* content, the user prompt and the assistant's reply are persisted to the
* history so subsequent calls continue the conversation. When `multiTurn`
* is `false`, history is treated as read-only — useful for stateless runners
* (e.g. judges) where every call should see only the initial config messages
* plus the current input. Failed calls leave the history unchanged so the
* next call can retry cleanly.
*
* @param input The user prompt string.
* When `input` is a pre-built `LDMessage[]` it is used as-is — config
* messages are not prepended and history is not updated.
*
* @param input The user prompt string, or a pre-built message array.
* @param outputType Optional JSON schema for structured output. When provided,
* the response is parsed and exposed via {@link RunnerResult.parsed}.
*/
async run(input: string, outputType?: Record<string, unknown>): Promise<RunnerResult> {
async run(input: string | LDMessage[], outputType?: Record<string, unknown>): Promise<RunnerResult> {
if (Array.isArray(input)) {
return outputType !== undefined
? this._runStructured(input, outputType)
: this._runCompletion(input);
}

const userMessage: LDMessage = { role: 'user', content: input };
const messages: LDMessage[] = [...this._history, userMessage];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,30 @@ describe('VercelModelRunner', () => {
expect(out.metrics.tokens).toEqual({ total: 100, input: 40, output: 60 });
});

it('uses a LDMessage[] directly without prepending config messages', async () => {
(generateText as jest.Mock).mockResolvedValue({
text: 'direct',
usage: { totalTokens: 5, promptTokens: 2, completionTokens: 3 },
});

const configWithMessages: LDAICompletionConfig = {
...baseConfig,
messages: [{ role: 'system', content: 'Should not appear' }],
};
const r = new VercelModelRunner(fakeModel as any, configWithMessages, {}, mockLogger);
const prebuilt = [
{ role: 'system' as const, content: 'Custom system' },
{ role: 'user' as const, content: 'Direct input' },
];
await r.run(prebuilt);

expect(generateText).toHaveBeenCalledWith({
model: fakeModel,
messages: prebuilt,
experimental_telemetry: { isEnabled: true },
});
});

it('returns success=false when generateText throws', async () => {
const err = new Error('boom');
(generateText as jest.Mock).mockRejectedValue(err);
Expand Down
38 changes: 24 additions & 14 deletions packages/ai-providers/server-ai-vercel/src/VercelModelRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { generateObject, generateText, jsonSchema, LanguageModel, ModelMessage }
import type {
LDAICompletionConfig,
LDLogger,
LDMessage,
Runner,
RunnerResult,
} from '@launchdarkly/server-sdk-ai';
Expand Down Expand Up @@ -38,25 +39,34 @@ export class VercelModelRunner implements Runner {
}

/**
* Run the Vercel AI model with the given user prompt.
* Run the Vercel AI model with the given user prompt or message array.
*
* The runner maintains a conversation history (as Vercel AI SDK
* `ModelMessage`s) that is initialized from any messages on the AI config
* (system prompt, etc.). On every invocation the user prompt is appended to
* the existing history before being sent to the model. When `multiTurn` is
* `true` (the default) and the call succeeds with non-empty content, the
* user prompt and the assistant's reply are persisted to the history so
* subsequent calls continue the conversation. When `multiTurn` is `false`,
* history is treated as read-only — useful for stateless runners (e.g.
* judges) where every call should see only the initial config messages
* plus the current input. Failed calls leave the history unchanged so the
* next call can retry cleanly.
* When `input` is a string, the runner maintains a conversation history (as
* Vercel AI SDK `ModelMessage`s) initialized from any messages on the AI
* config. The user prompt is appended to the existing history before being
* sent to the model. When `multiTurn` is `true` (the default) and the call
* succeeds with non-empty content, the user prompt and the assistant's reply
* are persisted to the history so subsequent calls continue the
* conversation. When `multiTurn` is `false`, history is treated as
* read-only — useful for stateless runners (e.g. judges) where every call
* should see only the initial config messages plus the current input. Failed
* calls leave the history unchanged so the next call can retry cleanly.
*
* @param input The user prompt string.
* When `input` is a pre-built `LDMessage[]` it is used as-is — config
* messages are not prepended and history is not updated.
*
* @param input The user prompt string, or a pre-built message array.
* @param outputType Optional JSON schema for structured output. When provided,
* the parsed object is exposed via {@link RunnerResult.parsed}.
*/
async run(input: string, outputType?: Record<string, unknown>): Promise<RunnerResult> {
async run(input: string | LDMessage[], outputType?: Record<string, unknown>): Promise<RunnerResult> {
if (Array.isArray(input)) {
const vercelMessages = convertMessagesToVercel(input) as ModelMessage[];
return outputType !== undefined
? this._runStructured(vercelMessages, outputType)
: this._runCompletion(vercelMessages);
}

const userMessage: ModelMessage = { role: 'user', content: input };
const messages: ModelMessage[] = [...this._history, userMessage];

Expand Down
66 changes: 1 addition & 65 deletions packages/sdk/server-ai/__tests__/Judge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,74 +2,10 @@ import { LDLogger } from '@launchdarkly/js-server-sdk-common';

import { LDAIConfigTracker } from '../src/api/config/LDAIConfigTracker';
import { LDAIJudgeConfig, LDMessage } from '../src/api/config/types';
import { Judge, stripLegacyJudgeMessages } from '../src/api/judge/Judge';
import { Judge } from '../src/api/judge/Judge';
import { RunnerResult } from '../src/api/model/types';
import { Runner } from '../src/api/providers/Runner';

describe('stripLegacyJudgeMessages', () => {
it('strips assistant messages containing {{message_history}}', () => {
const messages: LDMessage[] = [
{ role: 'system', content: 'You are a judge.' },
{ role: 'assistant', content: 'Here is the history: {{message_history}}' },
];
const result = stripLegacyJudgeMessages(messages);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('system');
});

it('strips user messages containing {{response_to_evaluate}}', () => {
const messages: LDMessage[] = [
{ role: 'system', content: 'You are a judge.' },
{ role: 'user', content: 'Evaluate: {{response_to_evaluate}}' },
];
const result = stripLegacyJudgeMessages(messages);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('system');
});

it('strips all legacy template messages from a typical legacy config', () => {
const messages: LDMessage[] = [
{ role: 'system', content: 'You are a judge.' },
{ role: 'assistant', content: '{{message_history}}' },
{ role: 'user', content: '{{response_to_evaluate}}' },
];
const result = stripLegacyJudgeMessages(messages);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('system');
});

it('does not strip system messages even when they contain template variables', () => {
const messages: LDMessage[] = [
{
role: 'system',
content: 'Judge using {{message_history}} and {{response_to_evaluate}}.',
},
];
const result = stripLegacyJudgeMessages(messages);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('system');
});

it('leaves non-system messages without template variables untouched', () => {
const messages: LDMessage[] = [
{ role: 'system', content: 'You are a judge.' },
{ role: 'user', content: 'This is a regular message.' },
];
const result = stripLegacyJudgeMessages(messages);
expect(result).toHaveLength(2);
});

it('returns an empty array for an empty input', () => {
expect(stripLegacyJudgeMessages([])).toEqual([]);
});

it('passes a new-style system-only config through unchanged', () => {
const messages: LDMessage[] = [{ role: 'system', content: 'You are a judge.' }];
const result = stripLegacyJudgeMessages(messages);
expect(result).toEqual(messages);
});
});

describe('Judge', () => {
let mockRunner: jest.Mocked<Runner>;
let mockTracker: jest.Mocked<LDAIConfigTracker>;
Expand Down
Loading
Loading