Skip to content
Merged
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
12 changes: 8 additions & 4 deletions examples/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,22 @@ export async function run(key: string, userInput: string): Promise<void> {
}).stream(userInput, newContext()),
);

let wroteChunk = false;
for await (const event of stream) {
if (event.type === 'chunk') {
process.stdout.write(event.text);
wroteChunk = true;
} else {
// Final event — full response + normalized usage
if (!wroteChunk) {
process.stdout.write(event.response ?? '');
}
process.stdout.write('\n\n');
process.stderr.write(`[debug] done event response length: ${event.response?.length ?? 0}\n`);
process.stderr.write(`[debug] done event response preview: ${String(event.response).slice(0, 100)}\n`);

process.stdout.write(`Usage: ${JSON.stringify(event.usage, null, 2)}\n`);
if (event.judgeResults) {
process.stderr.write(`[judges] ${JSON.stringify(event.judgeResults)}\n`);
process.stdout.write(`Judge results: ${JSON.stringify(event.judgeResults, null, 2)}\n`);
}
process.stdout.write('\n');
}
}
}
9 changes: 9 additions & 0 deletions packages/langchain-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,16 @@
"@opentelemetry/api": "^1.9.1",
"langchain": "^1.0.0"
},
"peerDependencies": {
"@langchain/aws": ">=1.0"
},
"peerDependenciesMeta": {
"@langchain/aws": {
"optional": true
}
},
"devDependencies": {
"@langchain/aws": "1.3.9",
"@opentelemetry/context-async-hooks": "^2.8.0",
"@opentelemetry/sdk-trace-base": "^2.8.0",
"@types/node": "^22.0.0",
Expand Down
118 changes: 116 additions & 2 deletions packages/langchain-agents/src/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ vi.mock('@langchain/core/tools', () => ({

const MockChatOpenAI = vi.hoisted(() => vi.fn().mockImplementation(() => ({})));
const MockChatAnthropic = vi.hoisted(() => vi.fn().mockImplementation(() => ({})));
const MockChatBedrockConverse = vi.hoisted(() => vi.fn().mockImplementation(() => ({})));

vi.mock('@langchain/openai', () => ({
ChatOpenAI: MockChatOpenAI,
Expand All @@ -42,6 +43,10 @@ vi.mock('@langchain/anthropic', () => ({
ChatAnthropic: MockChatAnthropic,
}));

vi.mock('@langchain/aws', () => ({
ChatBedrockConverse: MockChatBedrockConverse,
}));

vi.mock('@opentelemetry/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@opentelemetry/api')>();
return {
Expand Down Expand Up @@ -313,6 +318,20 @@ describe('createLangChainAgentsHandler', () => {
expect(modelSpan?.setAttribute).toHaveBeenCalledWith('gen_ai.usage.total_tokens', 12);
});

it.each([
['OpenAI', 'openai'],
['Bedrock', 'bedrock'],
['Azure', 'azure'],
['Anthropic', 'anthropic'],
['', 'openai'],
] as const)('sets gen_ai.provider.name from config %s', async (providerName, expected) => {
mockCreateAgent.mockReturnValue(makeCallbackAgent({ output: 'ok', inputTokens: 1, outputTokens: 1 }));
const cfg = { ...baseConfig, provider: { name: providerName } };
await createLangChainAgentsHandler({} as any)(cfg as any, 'q');
expect(mockSpan.setAttribute).toHaveBeenCalledWith('gen_ai.provider.name', expected);
expect(mockSpan.setAttribute).toHaveBeenCalledWith('gen_ai.system', 'langchain');
});

// ── LaunchDarkly correlation ────────────────────────────────────────────────
//
// The `feature_flag` span event is how the AI Config Monitoring tab locates a trace
Expand Down Expand Up @@ -964,6 +983,7 @@ describe('model source', () => {
beforeEach(() => {
MockChatOpenAI.mockClear();
MockChatAnthropic.mockClear();
MockChatBedrockConverse.mockClear();
mockCreateAgent.mockReset();
mockCreateAgent.mockReturnValue(makeMockAgent());
});
Expand All @@ -988,8 +1008,17 @@ describe('model source', () => {
MockChatOpenAI.mockImplementation(function MockChatOpenAI() {
return constructed;
});
await createLangChainAgentsHandler()(parameterized as any, 'q');
expect(MockChatOpenAI).toHaveBeenCalledWith({ temperature: 0.2, max_tokens: 512, model: 'gpt-4o' });
const cfg = {
...parameterized,
model: { ...parameterized.model, parameters: { ...parameterized.model.parameters, tools: ['openai-tool'] } },
};
await createLangChainAgentsHandler()(cfg as any, 'q');
expect(MockChatOpenAI).toHaveBeenCalledWith({
temperature: 0.2,
max_tokens: 512,
tools: ['openai-tool'],
model: 'gpt-4o',
});
expect(mockCreateAgent).toHaveBeenCalledWith(expect.objectContaining({ model: constructed }));
});

Expand All @@ -1008,6 +1037,91 @@ describe('model source', () => {
expect(mockCreateAgent).toHaveBeenCalledWith(expect.objectContaining({ model: constructed }));
});

it('prepends model.region onto the Bedrock model id once', async () => {
const constructed = { tag: 'bedrock' };
MockChatBedrockConverse.mockImplementation(function MockChatBedrockConverse() {
return constructed;
});
const cfg = {
...parameterized,
provider: { name: 'Bedrock' },
tools: {
search: { name: 'search', type: 'function', parameters: { type: 'object' }, description: 'Search' },
},
model: {
name: 'anthropic.claude-sonnet-4-5',
region: 'us',
parameters: { temperature: 0.2, tools: [{ name: 'duplicated-search' }] },
},
};
await createLangChainAgentsHandler()(cfg as any, 'q', { search: vi.fn() });
expect(MockChatBedrockConverse).toHaveBeenCalledWith({
temperature: 0.2,
model: 'us.anthropic.claude-sonnet-4-5',
});
expect(mockCreateAgent).toHaveBeenCalledWith(
expect.objectContaining({ model: constructed, tools: expect.arrayContaining([expect.anything()]) }),
);
expect(cfg.model.parameters.tools).toEqual([{ name: 'duplicated-search' }]);
expect(cfg.model.name).toBe('anthropic.claude-sonnet-4-5');
});

it('does not double a Bedrock inference-profile prefix', async () => {
const constructed = { tag: 'bedrock' };
MockChatBedrockConverse.mockImplementation(function MockChatBedrockConverse() {
return constructed;
});
const cfg = {
...parameterized,
provider: { name: 'Bedrock' },
model: { name: 'us.anthropic.claude-sonnet-4-5', region: 'us' },
};
await createLangChainAgentsHandler()(cfg as any, 'q');
expect(MockChatBedrockConverse).toHaveBeenCalledWith({ model: 'us.anthropic.claude-sonnet-4-5' });
});

it('leaves a Bedrock model name unchanged when region is absent', async () => {
const constructed = { tag: 'bedrock' };
MockChatBedrockConverse.mockImplementation(function MockChatBedrockConverse() {
return constructed;
});
const cfg = {
...parameterized,
provider: { name: 'Bedrock' },
model: { name: 'anthropic.claude-sonnet-4-5' },
};
await createLangChainAgentsHandler()(cfg as any, 'q');
expect(MockChatBedrockConverse).toHaveBeenCalledWith({ model: 'anthropic.claude-sonnet-4-5' });
});

it('ignores model.region for a non-Bedrock provider', async () => {
const constructed = { tag: 'openai' };
MockChatOpenAI.mockImplementation(function MockChatOpenAI() {
return constructed;
});
const cfg = {
...parameterized,
provider: { name: 'OpenAI' },
model: { name: 'gpt-4o', region: 'us' },
};
await createLangChainAgentsHandler()(cfg as any, 'q');
expect(MockChatOpenAI).toHaveBeenCalledWith({ model: 'gpt-4o' });
});

it('passes a prefixed Bedrock name to a factory without mutating the original config', async () => {
const llm = { invoke: vi.fn() };
const factory = vi.fn().mockReturnValue(llm);
const cfg = {
...parameterized,
provider: { name: 'Bedrock' },
model: { name: 'anthropic.claude-sonnet-4-5', region: 'us', parameters: { temperature: 0.2 } },
};
await createLangChainAgentsHandler(factory)(cfg as any, 'q');
expect(factory.mock.calls[0][0].model.name).toBe('us.anthropic.claude-sonnet-4-5');
expect(cfg.model.name).toBe('anthropic.claude-sonnet-4-5');
expect(factory.mock.calls[0][0]).not.toBe(cfg);
});

it('resolves a factory on the streaming path', async () => {
const llm = { invoke: vi.fn() };
const factory = vi.fn().mockReturnValue(llm);
Expand Down
4 changes: 2 additions & 2 deletions packages/langchain-agents/src/__tests__/spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ describe('langchain-agents span tree against a real tracer', () => {
expect(attrs['gen_ai.usage.input_tokens']).toBe(10);
expect(attrs['gen_ai.usage.output_tokens']).toBe(5);
expect(attrs['gen_ai.usage.total_tokens']).toBe(15);
// `gen_ai.provider.name` names who served the model; `langchain` is the framework and is not a
// member of the semconv enum. The legacy `gen_ai.system` keeps the value the handler shipped.
// `gen_ai.provider.name` is the configured provider, lower-cased. The legacy `gen_ai.system`
// keeps the shipped framework value.
expect(attrs['gen_ai.provider.name']).toBe('openai');
expect(attrs['gen_ai.system']).toBe('langchain');
});
Expand Down
54 changes: 41 additions & 13 deletions packages/langchain-agents/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,30 @@ import { toLangChainMessages } from './messages.js';
const TRACER_NAME = '@launchdarkly/ai-langchain-agents';

/**
* The provider that actually serves the model.
* The configured provider, lower-cased, for `gen_ai.provider.name`.
*
* `gen_ai.provider.name` names who served the request, and its semconv enum has no `langchain`
* member — LangChain is the framework, not the provider. This mirrors the choice
* `resolveBaseModel` makes, so the attribute agrees with the client that is really used.
* `gen_ai.system` keeps the `langchain` value the handler shipped, so existing dashboards do not
* break.
* member — LangChain is the framework, not the provider. Empty or missing names fall back to
* `openai`. `gen_ai.system` keeps the `langchain` value the handler shipped, so existing
* dashboards do not break.
*/
function servingProvider(config: AiConfigRep): string {
return (config.provider?.name ?? '').toLowerCase() === 'anthropic' ? 'anthropic' : 'openai';
return (config.provider?.name || 'openai').toLowerCase();
}

function resolvedModelName(config: AiConfigRep, fallbackName = ''): string {
const name = config.model?.name || fallbackName;
const provider = (config.provider?.name ?? '').toLowerCase();
if (provider !== 'bedrock') return name;
const prefix = config.model?.region ?? '';
if (!prefix || name.startsWith(`${prefix}.`)) return name;
return `${prefix}.${name}`;
}

function configForModelCall(config: AiConfigRep): AiConfigRep {
const resolved = resolvedModelName(config);
if (config.model?.name === resolved) return config;
return { ...config, model: { ...config.model, name: resolved } };
}

/**
Expand Down Expand Up @@ -246,22 +260,36 @@ export function buildSpanCallbacks(
export type LangChainModelSource = BaseChatModel | ((config: AiConfigRep) => BaseChatModel | Promise<BaseChatModel>);

function modelConstructorArgs(config: AiConfigRep, fallbackName: string): Record<string, unknown> {
const parameters =
config.model?.parameters && typeof config.model.parameters === 'object' ? config.model.parameters : {};
return { ...parameters, model: config.model?.name || fallbackName };
const parameters = {
...(config.model?.parameters && typeof config.model.parameters === 'object' ? config.model.parameters : {}),
};
if ((config.provider?.name ?? '').toLowerCase() === 'bedrock') delete parameters.tools;
return { ...parameters, model: resolvedModelName(config, fallbackName) };
}

async function resolveBaseModel(aiConfig: AiConfigRep, llm?: LangChainModelSource): Promise<BaseChatModel> {
if (typeof llm === 'function') return llm(aiConfig);
const invocation = configForModelCall(aiConfig);
if (typeof llm === 'function') return llm(invocation);
if (llm) return llm;
const provider = (aiConfig.provider?.name ?? '').toLowerCase();
const provider = (invocation.provider?.name ?? '').toLowerCase();
if (provider === 'anthropic') {
const { ChatAnthropic } = await import('@langchain/anthropic');
// biome-ignore lint/suspicious/noExplicitAny: parameter bag is caller-owned and not remapped
return new ChatAnthropic(modelConstructorArgs(aiConfig, 'claude-3-5-sonnet-20241022') as any);
return new ChatAnthropic(modelConstructorArgs(invocation, 'claude-3-5-sonnet-20241022') as any);
}
if (provider === 'bedrock') {
// biome-ignore lint/suspicious/noExplicitAny: @langchain/aws loaded via dynamic import with no static types
let mod: any;
try {
mod = await import('@langchain/aws');
} catch {
throw new Error('Using Bedrock models requires @langchain/aws. Install it with: npm install @langchain/aws');
}
// biome-ignore lint/suspicious/noExplicitAny: parameter bag is caller-owned and not remapped
return new mod.ChatBedrockConverse(modelConstructorArgs(invocation, '') as any);
}
// biome-ignore lint/suspicious/noExplicitAny: parameter bag is caller-owned and not remapped
return new ChatOpenAI(modelConstructorArgs(aiConfig, 'gpt-4o') as any);
return new ChatOpenAI(modelConstructorArgs(invocation, 'gpt-4o') as any);
}

const buildAgentTools = (configTools: Record<string, Tool>, toolHandlers: Record<string, ToolHandlerFn>) =>
Expand Down
5 changes: 5 additions & 0 deletions packages/langchain-messages/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,23 @@
},
"peerDependencies": {
"@langchain/anthropic": ">=0.3",
"@langchain/aws": ">=1.0",
"@langchain/openai": ">=1.0"
},
"peerDependenciesMeta": {
"@langchain/anthropic": {
"optional": true
},
"@langchain/aws": {
"optional": true
},
"@langchain/openai": {
"optional": true
}
},
"devDependencies": {
"@langchain/anthropic": "1.5.1",
"@langchain/aws": "1.3.9",
"@langchain/openai": "^1.4.7",
"@opentelemetry/context-async-hooks": "^2.8.0",
"@opentelemetry/sdk-trace-base": "^2.8.0",
Expand Down
Loading
Loading