diff --git a/AGENTS.md b/AGENTS.md index dc081f7..2d77554 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -404,7 +404,7 @@ Returns: **Behavior when `.invoke()` is called:** 1. Fetches and validates the `AiConfigRep` variation from LaunchDarkly using `args.key` and the supplied `context`. Throws if the variation is disabled or invalid. -2. Selects the handler by matching on `[config.provider.name, normalized mode]`. Selection priority: (a) exact provider match, (b) wildcard `['*', mode]` fallback for multi-provider adapters (e.g. LangChain). Throws if no matching handler is found. +2. Selects the handler by matching on `[config.provider.name, normalized mode]`. Selection priority: (a) exact provider match, ignoring any `providers` list; (b) a wildcard `['*', mode]` whose `providers` list contains the name, shortest list first and earlier registration on a tie; (c) an unscoped wildcard `['*', mode]`. Throws if no matching handler is found. 3. Invokes the selected handler with the config, user input, tool handlers, variables, and history. The `context` passed to `.invoke()` is automatically merged into `variables` under the key `ldContext`, so templates can reference `{{ldContext.key}}`, `{{ldContext.email}}`, etc. Any caller-supplied `ldContext` variable is silently overwritten. If `history` is provided, it is passed to the handler as the 5th positional argument — messages-mode handlers splice it into the messages array; agent-mode handlers append it to the system prompt. 4. Emits LaunchDarkly telemetry events: duration (`$ld:ai:duration:total`), outcome (`$ld:ai:generation:success` / `$ld:ai:generation:error`), and token counts (`$ld:ai:tokens:*`). 5. If `judgeConfiguration` is present: @@ -437,7 +437,7 @@ const registry = new Registry({ }); ``` -`.register(config)` can be called multiple times to add more handlers or tools. Duplicate `providesFor` keys or tool names produce a warning and the last registration wins. +`.register(config)` can be called multiple times to add more handlers or tools. Handler identity is `providesFor` plus the provider set: two wildcards of the same mode with different `providers` lists both stay registered, while the same set (order-independent; repeated names collapse) or two unscoped wildcards warn and the last registration wins, taking the earlier one's place in the order. A `providers` list on an exact handler is not part of its identity. Duplicate tool names do the same. `globalRegistry` is a pre-constructed singleton `Registry` instance. @@ -455,7 +455,7 @@ const combined = compose(globalRegistry, localRegistry); | Export | Description | |---|---| -| `createHandler(providesFor, handler)` | Attaches `providesFor` metadata to a handler function and returns it as a `ProviderHandler`. This is the canonical way to build any handler — both package-internal factories and user-supplied custom handlers. See [Factory Function](#factory-function). | +| `createHandler(providesFor, handler, options?)` | Attaches `providesFor` metadata to a handler function and returns it as a `ProviderHandler`. `options` is `{ stream?, captureContent?, providers? }`. This is the canonical way to build any handler — both package-internal factories and user-supplied custom handlers. See [Factory Function](#factory-function). | | `makeNodeTrackData(node, graphKey, runId)` | Builds the standard `TrackData` for a graph node event (including the `_ldMeta` model stamps). Native graph adapter packages must use this instead of building their own payload. | | `parseTemplate(template, variables)` | Replaces `{{variable}}` placeholders in a string. Supports dot-notation for nested values (e.g. `{{user.name}}`). Unrecognized placeholders are left as-is. | | `parseJSONWithPossibleFences(text)` | Parses a JSON string that may be wrapped in markdown code fences (` ```json ` or ` ``` `). Returns `null` if the text is not valid JSON. | @@ -488,7 +488,7 @@ A handler is a **callable that also carries metadata**. It must be both invokabl providesFor: [providerName: string, mode: 'agent' | 'messages'] ``` -The `providesFor` tuple is how `config()` routes to the correct handler at runtime. The mode element must exactly match the normalized `meta.mode`. The provider element must either exactly match `config.provider.name` **or** be the wildcard `'*'`. A wildcard handler is chosen only when no handler with an exact provider name matches — it acts as a fallback for multi-provider adapters like LangChain. **Always attach `providesFor` using `createHandler` rather than direct property assignment.** +The `providesFor` tuple is how `config()` routes to the correct handler at runtime. The mode element must exactly match the normalized `meta.mode`. The provider element must either exactly match `config.provider.name` **or** be the wildcard `'*'`. A wildcard handler is chosen only when no handler with an exact provider name matches — it acts as a fallback for multi-provider adapters like LangChain. Pass `providers: ['Bedrock']` as the fifth argument to `createHandler` to limit which `config.provider.name` values that wildcard accepts; names must match exactly, and an empty list or a blank name is rejected. Omit `providers` to leave the property unset, which accepts every provider. **Always attach `providesFor` using `createHandler` rather than direct property assignment.** ### Factory Function @@ -686,22 +686,24 @@ export function createMyProviderHandler(): ProviderHandler { async (config, userInput = '', toolHandlers = {}, variables = {}, history) => { // ... blocking implementation ... }, - async function* streamHandler(config, userInput = '', toolHandlers = {}, variables = {}, history) { - const span = trace.getTracer('my-package').startSpan('my.stream'); - try { - // ... stream from provider SDK ... - for await (const chunk of providerStream) { - yield { type: 'chunk', text: chunk.text }; + { + stream: async function* streamHandler(config, userInput = '', toolHandlers = {}, variables = {}, history) { + const span = trace.getTracer('my-package').startSpan('my.stream'); + try { + // ... stream from provider SDK ... + for await (const chunk of providerStream) { + yield { type: 'chunk', text: chunk.text }; + } + yield { type: 'done', output: fullText, usage: { input_tokens: 10, output_tokens: 20 } }; + span.setStatus({ code: SpanStatusCode.OK }); + } catch (err) { + span.recordException(err as Error); + span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) }); + throw err; + } finally { + span.end(); } - yield { type: 'done', output: fullText, usage: { input_tokens: 10, output_tokens: 20 } }; - span.setStatus({ code: SpanStatusCode.OK }); - } catch (err) { - span.recordException(err as Error); - span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) }); - throw err; - } finally { - span.end(); - } + }, }, ); } diff --git a/packages/client/etc/ai-server.api.md b/packages/client/etc/ai-server.api.md index a5c9cca..1fa98ba 100644 --- a/packages/client/etc/ai-server.api.md +++ b/packages/client/etc/ai-server.api.md @@ -126,15 +126,18 @@ export class ConversationIdSpanProcessor { } // Warning: (ae-forgotten-export) The symbol "HandlerInput" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "StreamHandlerInput" needs to be exported by the entry point index.d.ts // // @public -export function createHandler(providesFor: [string, 'agent' | 'messages'], handler: HandlerInput, streamHandler?: StreamHandlerInput, captureContent?: boolean): ProviderHandler; +export function createHandler(providesFor: [string, 'agent' | 'messages'], handler: HandlerInput, options?: { + stream?: StreamHandlerInput; + captureContent?: boolean; + providers?: readonly string[]; +}): ProviderHandler; // Warning: (ae-internal-missing-underscore) The name "createHandlerInternal" should be prefixed with an underscore because the declaration is marked as @internal // // @internal -export function createHandlerInternal(providesFor: [string, 'agent' | 'messages'], handler: HandlerInput, streamHandler?: StreamHandlerInput, captureContent?: boolean): ProviderHandler; +export function createHandlerInternal(providesFor: [string, 'agent' | 'messages'], handler: HandlerInput, streamHandler?: StreamHandlerInput, captureContent?: boolean, providers?: readonly string[]): ProviderHandler; // @public export function createRunUsage(): RunUsage; @@ -465,6 +468,7 @@ export type ProviderHandler = ((config: AiConfigRep, userInput?: string, toolHan usage?: Record; }>) & { providesFor?: [provider: string, type: 'agent' | 'messages']; + providers?: readonly string[]; captureContent?: boolean; stream?: (config: AiConfigRep, userInput?: string, toolHandlers?: Record, variables?: Record, history?: Message[]) => AsyncGenerator; }; @@ -697,6 +701,7 @@ export function withConversationId(id: string, fn: () => T): T; // Warnings were encountered during analysis: // // temp/dts/types.d.ts:370:5 - (ae-forgotten-export) The symbol "parseGraphTopology" needs to be exported by the entry point index.d.ts +// temp/dts/utils.d.ts:38:5 - (ae-forgotten-export) The symbol "StreamHandlerInput" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/client/src/__tests__/client.test.ts b/packages/client/src/__tests__/client.test.ts index 2b20c14..78457d0 100644 --- a/packages/client/src/__tests__/client.test.ts +++ b/packages/client/src/__tests__/client.test.ts @@ -371,6 +371,107 @@ describe('config() — multi-handler routing', () => { await expect(config({ key: 'flag', handler: [wildcardHandler] }).invoke('q', mockContext)).rejects.toThrow(); }); + const scoped = (providers: string[], output: string): ProviderHandler => { + const handler: ProviderHandler = vi.fn().mockResolvedValue({ output, usage: {} }); + handler.providesFor = ['*', 'messages']; + handler.providers = providers; + return handler; + }; + + it('selects a scoped wildcard when the provider is listed', async () => { + (extractVariation as ReturnType).mockResolvedValue({ + config: { ...mockConfig, provider: { name: 'Bedrock' } }, + meta: mockMeta, + }); + const handler = scoped(['Bedrock'], 'from bedrock'); + const result = await config({ key: 'flag', handler: [handler] }).invoke('q', mockContext); + expect(result.response).toBe('from bedrock'); + }); + + it('rejects a scoped wildcard when the provider is not listed', async () => { + const handler = scoped(['Bedrock'], 'from bedrock'); + await expect(config({ key: 'flag', handler: [handler] }).invoke('q', mockContext)).rejects.toThrow( + /^Handler for provider OpenAI not found$/, + ); + expect(handler).not.toHaveBeenCalled(); + }); + + it('prefers a scoped wildcard that lists the provider over an unscoped one', async () => { + (extractVariation as ReturnType).mockResolvedValue({ + config: { ...mockConfig, provider: { name: 'Bedrock' } }, + meta: mockMeta, + }); + const bedrock = scoped(['Bedrock'], 'bedrock'); + const unscoped: ProviderHandler = vi.fn().mockResolvedValue({ output: 'any', usage: {} }); + unscoped.providesFor = ['*', 'messages']; + const result = await config({ key: 'flag', handler: [unscoped, bedrock] }).invoke('q', mockContext); + expect(result.response).toBe('bedrock'); + expect(unscoped).not.toHaveBeenCalled(); + }); + + it('routes each provider to its own scoped wildcard', async () => { + (extractVariation as ReturnType).mockResolvedValue({ + config: { ...mockConfig, provider: { name: 'Anthropic' } }, + meta: mockMeta, + }); + const bedrock = scoped(['Bedrock'], 'bedrock'); + const anthropic = scoped(['Anthropic'], 'anthropic'); + const result = await config({ key: 'flag', handler: [bedrock, anthropic] }).invoke('q', mockContext); + expect(result.response).toBe('anthropic'); + expect(bedrock).not.toHaveBeenCalled(); + }); + + it('prefers an exact provider handler over a scoped wildcard', async () => { + const wildcard = scoped(['OpenAI'], 'scoped'); + const explicit = makeHandler('exact'); + const result = await config({ key: 'flag', handler: [wildcard, explicit] }).invoke('q', mockContext); + expect(result.response).toBe('exact'); + expect(wildcard).not.toHaveBeenCalled(); + }); + + it('falls back to an unscoped wildcard when no scoped list matches', async () => { + const bedrock = scoped(['Bedrock'], 'bedrock'); + const unscoped: ProviderHandler = vi.fn().mockResolvedValue({ output: 'any', usage: {} }); + unscoped.providesFor = ['*', 'messages']; + const result = await config({ key: 'flag', handler: [bedrock, unscoped] }).invoke('q', mockContext); + expect(result.response).toBe('any'); + expect(bedrock).not.toHaveBeenCalled(); + }); + + it('prefers the shorter provider list when both match', async () => { + (extractVariation as ReturnType).mockResolvedValue({ + config: { ...mockConfig, provider: { name: 'Bedrock' } }, + meta: mockMeta, + }); + const wide = scoped(['Bedrock', 'Anthropic'], 'wide'); + const narrow = scoped(['Bedrock'], 'narrow'); + const result = await config({ key: 'flag', handler: [wide, narrow] }).invoke('q', mockContext); + expect(result.response).toBe('narrow'); + expect(wide).not.toHaveBeenCalled(); + }); + + it('keeps the earlier handler when overlapping lists are the same length', async () => { + (extractVariation as ReturnType).mockResolvedValue({ + config: { ...mockConfig, provider: { name: 'Bedrock' } }, + meta: mockMeta, + }); + const earlier = scoped(['Bedrock', 'Gemini'], 'earlier'); + const later = scoped(['Bedrock', 'Anthropic'], 'later'); + const result = await config({ key: 'flag', handler: [earlier, later] }).invoke('q', mockContext); + expect(result.response).toBe('earlier'); + expect(later).not.toHaveBeenCalled(); + }); + + it('a scoped wildcard does not match a different mode', async () => { + (extractVariation as ReturnType).mockResolvedValue({ + config: { ...mockConfig, provider: { name: 'Bedrock' } }, + meta: { ...mockMeta, mode: 'agent' as const }, + }); + const handler = scoped(['Bedrock'], 'bedrock'); + await expect(config({ key: 'flag', handler: [handler] }).invoke('q', mockContext)).rejects.toThrow(/Bedrock/); + expect(handler).not.toHaveBeenCalled(); + }); + it('throws a mode-specific error when provider matches but mode does not', async () => { const messagesHandler = makeHandler(); (extractVariation as ReturnType).mockResolvedValue({ diff --git a/packages/client/src/__tests__/graph.test.ts b/packages/client/src/__tests__/graph.test.ts index 3b9f7a8..88a6523 100644 --- a/packages/client/src/__tests__/graph.test.ts +++ b/packages/client/src/__tests__/graph.test.ts @@ -310,6 +310,15 @@ describe('graph().invoke()', () => { await expect(graph('graph-flag', {}).invoke('hi', mockContext)).rejects.toThrow(/handlers/i); }); + it('does not run a scoped wildcard for a provider outside its list', async () => { + setupTwoNodeGraph(); + const handler = makeHandler(); + handler.providesFor = ['*', 'messages']; + handler.providers = ['Bedrock']; + await expect(graph('graph-flag', { handlers: [handler] }).invoke('hi', mockContext)).rejects.toThrow(/OpenAI/); + expect(handler).not.toHaveBeenCalled(); + }); + it('visits all nodes and accumulates usage', async () => { setupTwoNodeGraph(); const handler = makeHandler(); @@ -474,6 +483,18 @@ describe('graph().invoke()', () => { expect(result.judgeResults).toEqual(judgeData); }); + it('passes the graph handlers to the graphJudge, so its provider allowlist applies', async () => { + const { runJudges } = await import('../judges.js'); + (runJudges as ReturnType).mockResolvedValue({}); + setupTwoNodeGraph(); + const handler = makeHandler(); + await graph('graph-flag', { handlers: [handler], graphJudge: 'graph-judge' }).invoke('hi', mockContext); + const graphJudgeCall = (runJudges as ReturnType).mock.calls.find( + ([args]) => args.config.judgeConfiguration?.judges?.[0]?.key === 'graph-judge', + ); + expect(graphJudgeCall?.[0].handlers).toEqual([handler]); + }); + it('forwards history as the 4th invoke arg to the root handler only', async () => { setupTwoNodeGraph(); const calls: Array<{ input: unknown; history: unknown }> = []; diff --git a/packages/client/src/__tests__/judges.test.ts b/packages/client/src/__tests__/judges.test.ts index 5a68e32..62a947b 100644 --- a/packages/client/src/__tests__/judges.test.ts +++ b/packages/client/src/__tests__/judges.test.ts @@ -216,6 +216,64 @@ describe('runJudges', () => { expect(callArgs.config).toBe(mockJudgeConfig); }); + it('skips a scoped wildcard when the judge provider is outside its list', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const scoped: ProviderHandler = vi.fn().mockResolvedValue({ output: '{"score":1,"reasoning":"ok"}', usage: {} }); + scoped.providesFor = ['*', 'agent']; + scoped.providers = ['Bedrock']; + const parent = makeHandler(); + mockExtractVariation.mockResolvedValue({ + config: { ...mockJudgeConfig, provider: { name: 'Anthropic' } }, + meta: mockJudgeMeta, + }); + const config = { + model: { name: 'gpt-4o' }, + provider: { name: 'OpenAI' }, + instructions: 'Be helpful.', + judgeConfiguration: { judges: [{ key: 'judge-flag', samplingRate: 1 }] }, + }; + await runJudges({ + config, + userContext: mockContext, + handler: parent, + handlers: [scoped], + userInput: 'hello', + llmResponse: 'world', + baseTrackData, + }); + expect(mockExecuteAndTrack).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith("Judge 'judge-flag' skipped: no handler covers provider Anthropic"); + warn.mockRestore(); + }); + + it.each([ + ['Bedrock', true], + ['Anthropic', false], + ])('falls back to a Bedrock-scoped parent only for a listed provider (%s)', async (provider, runs) => { + const parent: ProviderHandler = vi.fn().mockResolvedValue({ output: '{"score":1,"reasoning":"ok"}', usage: {} }); + parent.providesFor = ['*', 'messages']; + parent.providers = ['Bedrock']; + mockExtractVariation.mockResolvedValue({ + config: { ...mockJudgeConfig, provider: { name: provider } }, + meta: mockJudgeMeta, + }); + await runJudges({ + config: { + model: { name: 'm' }, + provider: { name: 'Bedrock' }, + instructions: 'Be helpful.', + judgeConfiguration: { judges: [{ key: 'judge-flag', samplingRate: 1 }] }, + }, + userContext: mockContext, + handler: parent, + handlers: [], + userInput: 'hello', + llmResponse: 'world', + baseTrackData, + }); + expect(mockExecuteAndTrack).toHaveBeenCalledTimes(runs ? 1 : 0); + }); + it('skips a judge when no compatible handler is found (mismatched provider, no wildcard)', async () => { const openaiHandler = makeHandler(); // ['OpenAI', 'messages'] mockExtractVariation.mockResolvedValue({ @@ -426,4 +484,13 @@ describe('runJudge result trackData', () => { expect(result!.trackData.modelKey).toBe('judge-model'); expect(result!.trackData.modelVersion).toBe(2); }); + + it('returns null for a provider outside the only scoped handler list', async () => { + const scoped: ProviderHandler = vi.fn(); + scoped.providesFor = ['*', 'messages']; + scoped.providers = ['Bedrock']; + const task = { ...makeTask(parentTrackData), judgeProvider: 'Anthropic' } as JudgeTask; + expect(await runJudge(task, [scoped])).toBeNull(); + expect(mockExecuteAndTrack).not.toHaveBeenCalled(); + }); }); diff --git a/packages/client/src/__tests__/registry.test.ts b/packages/client/src/__tests__/registry.test.ts index c93899f..e481aad 100644 --- a/packages/client/src/__tests__/registry.test.ts +++ b/packages/client/src/__tests__/registry.test.ts @@ -4,9 +4,10 @@ import type { ProviderHandler } from '../types.js'; // ─── helpers ────────────────────────────────────────────────────────────────── -const makeHandler = (provider: string, mode: 'agent' | 'messages'): ProviderHandler => { +const makeHandler = (provider: string, mode: 'agent' | 'messages', providers?: readonly string[]): ProviderHandler => { const h: ProviderHandler = async () => ({ output: 'ok' }); h.providesFor = [provider, mode]; + if (providers) h.providers = providers; return h; }; @@ -47,6 +48,100 @@ describe('Registry', () => { warn.mockRestore(); }); + it('keeps scoped wildcards with different provider lists', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const r = new Registry(); + const bedrock = makeHandler('*', 'agent', ['Bedrock']); + const anthropic = makeHandler('*', 'agent', ['Anthropic']); + r.register({ handlers: [bedrock, anthropic] }); + expect(r.handlers).toEqual([bedrock, anthropic]); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('keeps an unscoped wildcard beside a scoped one', () => { + const r = new Registry(); + const unscoped = makeHandler('*', 'agent'); + const scoped = makeHandler('*', 'agent', ['Bedrock']); + r.register({ handlers: [unscoped, scoped] }); + expect(r.handlers).toEqual([unscoped, scoped]); + }); + + it('warns and replaces the same provider set regardless of order', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const r = new Registry(); + const first = makeHandler('*', 'agent', ['Anthropic', 'Bedrock']); + const second = makeHandler('*', 'agent', ['Bedrock', 'Anthropic']); + r.register({ handlers: [first] }); + r.register({ handlers: [second] }); + expect(r.handlers).toEqual([second]); + expect(warn).toHaveBeenCalledOnce(); + } finally { + warn.mockRestore(); + } + }); + + it('treats a repeated provider name as the same identity', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const r = new Registry(); + const first = makeHandler('*', 'agent', ['Bedrock']); + const second = makeHandler('*', 'agent', ['Bedrock', 'Bedrock']); + r.register({ handlers: [first, second] }); + expect(r.handlers).toEqual([second]); + expect(warn).toHaveBeenCalledOnce(); + } finally { + warn.mockRestore(); + } + }); + + it('replaces a re-registered handler in its original slot', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const r = new Registry(); + const bg = makeHandler('*', 'messages', ['Bedrock', 'Gemini']); + const ba = makeHandler('*', 'messages', ['Bedrock', 'Anthropic']); + const bgAgain = makeHandler('*', 'messages', ['Gemini', 'Bedrock']); + r.register({ handlers: [bg, ba] }); + r.register({ handlers: [bgAgain] }); + expect(r.handlers).toEqual([bgAgain, ba]); + } finally { + warn.mockRestore(); + } + }); + + it('keeps a comma inside a provider name apart from two names', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const r = new Registry(); + const joined = makeHandler('*', 'messages', ['a,b']); + const split = makeHandler('*', 'messages', ['a', 'b']); + r.register({ handlers: [joined, split] }); + expect(r.handlers).toEqual([joined, split]); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('ignores a provider list on an exact handler, so two exact handlers collide', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const r = new Registry(); + const plain = makeHandler('OpenAI', 'messages'); + const listed = makeHandler('OpenAI', 'messages', ['OpenAI']); + r.register({ handlers: [plain, listed] }); + expect(r.handlers).toEqual([listed]); + expect(warn).toHaveBeenCalledOnce(); + } finally { + warn.mockRestore(); + } + }); + it('always appends handlers without providesFor (no dedup)', () => { const r = new Registry(); const h: ProviderHandler = async () => ({ output: '' }); @@ -105,6 +200,25 @@ describe('compose', () => { warn.mockRestore(); }); + it('keeps scoped wildcards with different lists', () => { + const bedrock = makeHandler('*', 'agent', ['Bedrock']); + const anthropic = makeHandler('*', 'agent', ['Anthropic']); + const c = compose(new Registry({ handlers: [bedrock] }), new Registry({ handlers: [anthropic] })); + expect(c.handlers).toEqual([bedrock, anthropic]); + }); + + it('replaces the same provider set', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const first = makeHandler('*', 'agent', ['Bedrock', 'Anthropic']); + const second = makeHandler('*', 'agent', ['Anthropic', 'Bedrock']); + const c = compose(new Registry({ handlers: [first] }), new Registry({ handlers: [second] })); + expect(c.handlers).toEqual([second]); + } finally { + warn.mockRestore(); + } + }); + it('b tool wins over a on the same tool key', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const fnA = () => 'a'; diff --git a/packages/client/src/__tests__/utils.test.ts b/packages/client/src/__tests__/utils.test.ts index 704a4f0..68aa7c2 100644 --- a/packages/client/src/__tests__/utils.test.ts +++ b/packages/client/src/__tests__/utils.test.ts @@ -234,6 +234,58 @@ describe('createHandler', () => { expect(handler.providesFor).toEqual(['MyProvider', 'agent']); }); + it('leaves providers unset when omitted', () => { + const handler = createHandler(['*', 'messages'], makeRawHandler() as any); + expect(handler.providers).toBeUndefined(); + }); + + it('attaches a provider list', () => { + const handler = createHandler(['*', 'messages'], makeRawHandler() as any, { providers: ['Bedrock', 'Anthropic'] }); + expect(handler.providesFor).toEqual(['*', 'messages']); + expect(handler.providers).toEqual(['Bedrock', 'Anthropic']); + }); + + it('takes stream and captureContent as options', () => { + const stream = vi.fn(); + const handler = createHandler(['MyProvider', 'messages'], makeRawHandler() as any, { + stream: stream as any, + captureContent: true, + }); + expect(handler.stream).toBe(stream); + expect(handler.captureContent).toBe(true); + }); + + it('drops repeated provider names, keeping first-seen order', () => { + const handler = createHandler(['*', 'messages'], makeRawHandler() as any, { + providers: ['Bedrock', 'Anthropic', 'Bedrock'], + }); + expect(handler.providers).toEqual(['Bedrock', 'Anthropic']); + }); + + it('copies the provider list, so a later change to the caller array has no effect', () => { + const providers = ['Bedrock']; + const handler = createHandler(['*', 'messages'], makeRawHandler() as any, { providers }); + providers.push('OpenAI'); + expect(handler.providers).toEqual(['Bedrock']); + expect(Object.isFrozen(handler.providers)).toBe(true); + }); + + it('rejects an empty provider list', () => { + expect(() => createHandler(['*', 'messages'], makeRawHandler() as any, { providers: [] })).toThrow(/providers/i); + }); + + it('rejects a blank provider name', () => { + expect(() => createHandler(['*', 'messages'], makeRawHandler() as any, { providers: ['Bedrock', ''] })).toThrow( + /providers/i, + ); + }); + + it('rejects a provider name with surrounding whitespace', () => { + expect(() => createHandler(['*', 'messages'], makeRawHandler() as any, { providers: [' Bedrock'] })).toThrow( + /providers/i, + ); + }); + it('returns the same function reference', () => { const fn = makeRawHandler(); const handler = createHandler(['MyProvider', 'agent'], fn as any); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index b68b516..19b1956 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -14,7 +14,7 @@ import type { StreamEvent, VariationMeta, } from './types.js'; -import { normalizeMode, parseJSONWithPossibleFences } from './utils.js'; +import { coversProviderName, normalizeMode, parseJSONWithPossibleFences, selectModeHandler } from './utils.js'; function resolveOutputFormatResponse(rawResponse: unknown, outputFormat: Record | undefined): unknown { if (!outputFormat) return rawResponse ?? ''; @@ -43,13 +43,10 @@ function selectHandler( if (!provider) throw new Error('Provider not found'); const normalizedMode = normalizeMode(mode); - const exact = resolvedHandlers?.find((h) => h.providesFor?.[0] === provider && h.providesFor?.[1] === normalizedMode); - if (exact) return exact; + const chosen = resolvedHandlers ? selectModeHandler(resolvedHandlers, provider, normalizedMode) : undefined; + if (chosen) return chosen; - const wildcard = resolvedHandlers?.find((h) => h.providesFor?.[0] === '*' && h.providesFor?.[1] === normalizedMode); - if (wildcard) return wildcard; - - if (!resolvedHandlers?.some((h) => h.providesFor?.[0] === provider || h.providesFor?.[0] === '*')) { + if (!resolvedHandlers?.some((handler) => coversProviderName(handler, provider))) { throw new Error(`Handler for provider ${provider} not found`); } throw new Error(`Handler for provider ${provider} with mode ${normalizedMode} not found`); diff --git a/packages/client/src/graph.ts b/packages/client/src/graph.ts index c53f892..3e40eab 100644 --- a/packages/client/src/graph.ts +++ b/packages/client/src/graph.ts @@ -27,7 +27,7 @@ import { type TraverseVisitor, type VariationMeta, } from './types.js'; -import { endSpanOnce, normalizeMode, setLdSpanAttributes } from './utils.js'; +import { endSpanOnce, normalizeMode, selectModeHandler, setLdSpanAttributes } from './utils.js'; // Cycle protection: cap how many BFS layers a traversal will expand. const MAX_TRAVERSAL_DEPTH = 100; @@ -55,16 +55,19 @@ const selectHandler = (config: AiConfigRep, meta: VariationMeta, handlers: Provi const mode = normalizeMode(meta.mode); - const exact = handlers.find((h) => h.providesFor?.[0] === provider && h.providesFor?.[1] === mode); - if (exact) return exact; - - const wildcard = handlers.find((h) => h.providesFor?.[0] === '*' && h.providesFor?.[1] === mode); - if (wildcard) return wildcard; + const chosen = selectModeHandler(handlers, provider, mode); + if (chosen) return chosen; const byProvider = handlers.find((h) => h.providesFor?.[0] === provider); if (byProvider) return byProvider; - if (handlers.length === 1) return handlers[0]; + if (handlers.length === 1) { + const only = handlers[0]; + if (only.providers !== undefined && !only.providers.includes(provider)) { + throw new Error(`Handler for provider ${provider} not found`); + } + return only; + } throw new Error(`Handler for provider ${provider} not found`); }; @@ -809,6 +812,7 @@ export const graphInternal = (key: string, options: GraphOptions): GraphCaller = } as unknown as AiConfigRep, userContext: context, handler: selectHandler(judgeRoot.config, judgeRoot.meta, judgeHandlers), + handlers: judgeHandlers, userInput: resolvedInput, llmResponse: finalResponse, baseTrackData: graphTrackData, diff --git a/packages/client/src/judges.ts b/packages/client/src/judges.ts index 5677d04..d9c8cbe 100644 --- a/packages/client/src/judges.ts +++ b/packages/client/src/judges.ts @@ -19,8 +19,30 @@ import { normalizeMode, omitModelStamps, parseJSONWithPossibleFences, + selectModeHandler, + wildcardCovers, } from './utils.js'; +function parentCovers(handler: ProviderHandler, provider: string | undefined): boolean { + if (provider !== undefined && handler.providesFor?.[0] === provider) return true; + return wildcardCovers(handler, provider); +} + +/** Exact provider+mode, then the best wildcard of that mode, then an agent-mode fallback for a messages judge. */ +function selectJudgeExecution( + handlers: readonly ProviderHandler[], + provider: string | undefined, + mode: 'agent' | 'messages', +): { handler: ProviderHandler; collapseMessages: boolean } | undefined { + const chosen = selectModeHandler(handlers, provider, mode); + if (chosen) return { handler: chosen, collapseMessages: false }; + if (mode === 'messages') { + const agentFallback = selectModeHandler(handlers, provider, 'agent'); + if (agentFallback) return { handler: agentFallback, collapseMessages: true }; + } + return undefined; +} + /** * Resolves a judge's own AI Config, returning `null` instead of throwing when it * cannot be resolved. @@ -110,10 +132,11 @@ export const runJudges = async ({ // Pick the handler matching the judge's own provider/mode. Priority: // 1. Exact provider + mode match - // 2. Wildcard provider (`*`) + same mode (e.g. LangChain agents handler) + // 2. Wildcard provider (`*`) + same mode (e.g. LangChain agents handler) whose + // `providers` list, if any, contains the judge's provider; the shortest list wins // 3. Same provider, any mode (agent handler for the same provider) - // 4. Wildcard provider, any mode — agent-handler fallback for any provider - // 5. Parent node's handler, if it covers the same provider (or is a wildcard) + // 4. Wildcard provider, any mode, under the same `providers` rule — agent-handler fallback + // 5. Parent node's handler, if it names the provider or is a wildcard whose list allows it // 6. Skip — crossing providers would send the wrong model to an incompatible API. // When falling back to an agent-mode handler for a messages-mode judge config // (cases 2–5), messages are collapsed into a single instructions string so the @@ -121,26 +144,18 @@ export const runJudges = async ({ let judgeHandler: ProviderHandler; let collapseMessages = false; if (handlers) { - const matchesProvider = (h: ProviderHandler) => - h.providesFor?.[0] === judgeProvider || h.providesFor?.[0] === '*'; - - const exactMatch = handlers.find((h) => matchesProvider(h) && h.providesFor?.[1] === judgeMode); - const agentFallback = - judgeMode === 'messages' - ? handlers.find((h) => matchesProvider(h) && h.providesFor?.[1] === 'agent') - : undefined; - - if (exactMatch) { - judgeHandler = exactMatch; - } else if (agentFallback) { - judgeHandler = agentFallback; - collapseMessages = true; - } else if (handler.providesFor?.[0] === judgeProvider || handler.providesFor?.[0] === '*') { + const selected = selectJudgeExecution(handlers, judgeProvider, judgeMode); + if (selected) { + judgeHandler = selected.handler; + collapseMessages = selected.collapseMessages; + } else if (parentCovers(handler, judgeProvider)) { judgeHandler = handler; collapseMessages = judgeMode === 'messages' && handler.providesFor?.[1] === 'agent'; } else { // No compatible handler found — skip this judge rather than calling the // wrong provider's API with an incompatible model name. + // biome-ignore lint/suspicious/noConsole: judges are non-fatal; say why one was skipped + console.warn(`Judge '${judge.key}' skipped: no handler covers provider ${judgeProvider}`); continue; } } else { @@ -259,23 +274,15 @@ export const buildJudgeTasksInternal = async ({ let collapseMessages = false; if (handlers) { - const matchesProvider = (h: ProviderHandler) => - h.providesFor?.[0] === judgeProvider || h.providesFor?.[0] === '*'; - - const exactMatch = handlers.find((h) => matchesProvider(h) && h.providesFor?.[1] === judgeMode); - const agentFallback = - judgeMode === 'messages' - ? handlers.find((h) => matchesProvider(h) && h.providesFor?.[1] === 'agent') - : undefined; - - if (exactMatch) { - collapseMessages = false; - } else if (agentFallback) { - collapseMessages = true; - } else if (handler.providesFor?.[0] === judgeProvider || handler.providesFor?.[0] === '*') { + const selected = selectJudgeExecution(handlers, judgeProvider, judgeMode); + if (selected) { + collapseMessages = selected.collapseMessages; + } else if (parentCovers(handler, judgeProvider)) { collapseMessages = judgeMode === 'messages' && handler.providesFor?.[1] === 'agent'; } else { // No compatible handler — skip, same as runJudges. + // biome-ignore lint/suspicious/noConsole: judges are non-fatal; say why one was skipped + console.warn(`Judge '${judge.key}' skipped: no handler covers provider ${judgeProvider}`); continue; } } @@ -325,16 +332,9 @@ export const runJudge = async (task: JudgeTask, handlers: ProviderHandler[]): Pr configKey, } = task; - const matchesProvider = (h: ProviderHandler) => h.providesFor?.[0] === judgeProvider || h.providesFor?.[0] === '*'; - - const exactMatch = handlers.find((h) => matchesProvider(h) && h.providesFor?.[1] === judgeMode); - const agentFallback = - judgeMode === 'messages' && !exactMatch - ? handlers.find((h) => matchesProvider(h) && h.providesFor?.[1] === 'agent') - : undefined; - - const judgeHandler = exactMatch ?? agentFallback; - if (!judgeHandler) return null; + const selected = selectJudgeExecution(handlers, judgeProvider, judgeMode); + if (!selected) return null; + const judgeHandler = selected.handler; const effectiveConfig = collapseMessages ? collapseMessagesToInstructions(judgeConfig) : judgeConfig; diff --git a/packages/client/src/registry.ts b/packages/client/src/registry.ts index 145e6cf..46a8e75 100644 --- a/packages/client/src/registry.ts +++ b/packages/client/src/registry.ts @@ -1,5 +1,17 @@ import type { NativeTool, ProviderHandler, ToolHandlerFn } from './types.js'; +/** + * `providesFor`, plus the allowlist as a set on a `'*'` handler. Order and repeated + * names do not make a new identity. Routing ignores the list on an exact handler, + * so it is not part of that handler's identity. + */ +function registrationKey(handler: ProviderHandler): string | undefined { + if (!handler.providesFor) return undefined; + const [provider, mode] = handler.providesFor; + if (provider !== '*' || handler.providers === undefined) return JSON.stringify([provider, mode]); + return JSON.stringify([provider, mode, [...new Set(handler.providers)].sort()]); +} + export class Registry { handlers: ProviderHandler[] = []; tools: Record = {}; @@ -12,16 +24,14 @@ export class Registry { register(config: { handlers?: ProviderHandler[]; tools?: Record }): void { for (const handler of config.handlers ?? []) { - const key = handler.providesFor ? `${handler.providesFor[0]}:${handler.providesFor[1]}` : undefined; - if (key) { - const existing = this.handlers.find((h) => h.providesFor && `${h.providesFor[0]}:${h.providesFor[1]}` === key); - if (existing) { - // biome-ignore lint/suspicious/noConsole: intentional warning for user misconfiguration - console.warn(`Multiple handlers registered for [${key}]. This may be unintended. The last one will be used.`); - this.handlers = this.handlers.filter( - (h) => !h.providesFor || `${h.providesFor[0]}:${h.providesFor[1]}` !== key, - ); - } + const key = registrationKey(handler); + const index = key ? this.handlers.findIndex((h) => registrationKey(h) === key) : -1; + if (index !== -1) { + // biome-ignore lint/suspicious/noConsole: intentional warning for user misconfiguration + console.warn(`Multiple handlers registered for ${key}. This may be unintended. The last one will be used.`); + // In place, so a replacement keeps the registration order that breaks wildcard ties. + this.handlers[index] = handler; + continue; } this.handlers.push(handler); } diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index bea3f66..ea5b27c 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -666,6 +666,11 @@ export type ProviderHandler = (( usage?: Record; }>) & { providesFor?: [provider: string, type: 'agent' | 'messages']; + /** + * Allowlist consulted only when `providesFor` starts with `'*'`. Names must match + * `config.provider.name` exactly. Omitted means the wildcard accepts every provider. + */ + providers?: readonly string[]; /** * Whether this handler was built with content capture on. Declared here so the client core can * apply the same gate to content it writes on the handler's behalf — notably the judge's diff --git a/packages/client/src/utils.ts b/packages/client/src/utils.ts index 4fbdde2..b288e45 100644 --- a/packages/client/src/utils.ts +++ b/packages/client/src/utils.ts @@ -35,19 +35,34 @@ type StreamHandlerInput = ( * The returned value is the same function reference; `createHandler` mutates * in place rather than wrapping. * - * Pass an optional `streamHandler` as the third argument to attach a streaming - * implementation. When present, `model().stream()` will call it instead of - * the blocking handler, yielding text chunks in real time. When absent, - * `model().stream()` falls back to the blocking handler. + * Options: + * - `stream` attaches a streaming implementation. When present, `model().stream()` + * calls it instead of the blocking handler, yielding text chunks in real time. + * When absent, `model().stream()` falls back to the blocking handler. + * - `captureContent` sets the handler's content-capture default. + * - `providers` is consulted only when `providesFor` starts with `'*'`. It limits + * which `config.provider.name` values that wildcard accepts. Names match exactly, + * repeats are dropped, and the list is copied. Omit it to leave the property + * unset, which means the wildcard accepts every provider. + * + * Calling `createHandler` again on the same function overwrites the options + * passed this time and keeps any earlier `captureContent` or `providers` that + * are not passed. */ export function createHandler( providesFor: [string, 'agent' | 'messages'], handler: HandlerInput, - streamHandler?: StreamHandlerInput, - captureContent?: boolean, + options: { stream?: StreamHandlerInput; captureContent?: boolean; providers?: readonly string[] } = {}, ): ProviderHandler { reportUsage('client.createHandler'); - return createHandlerInternal(providesFor, handler, streamHandler, captureContent); + return createHandlerInternal(providesFor, handler, options.stream, options.captureContent, options.providers); +} + +function normalizeProviders(providers: readonly string[]): readonly string[] { + if (providers.length === 0 || providers.some((name) => name === '' || name !== name.trim())) { + throw new Error('providers must be a non-empty list of provider names without surrounding whitespace'); + } + return Object.freeze([...new Set(providers)]); } /** @@ -62,14 +77,72 @@ export function createHandlerInternal( handler: HandlerInput, streamHandler?: StreamHandlerInput, captureContent?: boolean, + providers?: readonly string[], ): ProviderHandler { + const allow = providers === undefined ? undefined : normalizeProviders(providers); const ph = handler as ProviderHandler; ph.providesFor = providesFor; if (streamHandler) ph.stream = streamHandler; if (captureContent !== undefined) ph.captureContent = captureContent; + if (allow !== undefined) ph.providers = allow; return ph; } +/** Whether a `'*'` handler accepts `provider`. An allowlist is a filter; an absent one accepts every name. */ +export function wildcardCovers(handler: ProviderHandler, provider: string | undefined): boolean { + if (handler.providesFor?.[0] !== '*') return false; + const allow = handler.providers; + if (allow === undefined) return true; + return provider !== undefined && allow.includes(provider); +} + +/** + * The `'*'` handler of `mode` that accepts `provider`. + * + * A scoped list beats an unscoped wildcard. Among scoped lists that contain + * the name, the shorter list wins, and equal lengths keep the earlier registration. + */ +export function bestWildcard( + handlers: readonly ProviderHandler[], + provider: string | undefined, + mode: 'agent' | 'messages', +): ProviderHandler | undefined { + let bestScoped: { length: number; handler: ProviderHandler } | undefined; + let unscoped: ProviderHandler | undefined; + for (const handler of handlers) { + if (handler.providesFor?.[1] !== mode || !wildcardCovers(handler, provider)) continue; + const allow = handler.providers; + if (allow === undefined) { + if (!unscoped) unscoped = handler; + continue; + } + if (!bestScoped || allow.length < bestScoped.length) { + bestScoped = { length: allow.length, handler }; + } + } + return bestScoped?.handler ?? unscoped; +} + +/** Exact `(provider, mode)` first, then the best wildcard of that mode. */ +export function selectModeHandler( + handlers: readonly ProviderHandler[], + provider: string | undefined, + mode: 'agent' | 'messages', +): ProviderHandler | undefined { + if (provider) { + const exact = handlers.find( + (handler) => handler.providesFor?.[0] === provider && handler.providesFor?.[1] === mode, + ); + if (exact) return exact; + } + return bestWildcard(handlers, provider, mode); +} + +/** True when the handler names `provider` or its wildcard allowlist contains it, in any mode. */ +export function coversProviderName(handler: ProviderHandler, provider: string): boolean { + return handler.providesFor?.[0] === provider || wildcardCovers(handler, provider); +} + /** * Normalizes a variation mode to the handler type used in `providesFor`. * Both `'completion'` and `'judge'` configs are served by the `'messages'` diff --git a/packages/langchain-agents/README.md b/packages/langchain-agents/README.md index f26163d..fd91bf7 100644 --- a/packages/langchain-agents/README.md +++ b/packages/langchain-agents/README.md @@ -4,6 +4,8 @@ LangChain handler for `@launchdarkly/ai-server` using **LangGraph's `createReact **`providesFor`:** `['*', 'agent']` — matches any flag variation where `meta.mode` is `"agent"` and no more-specific handler is registered. LangChain is a framework adapter, not a provider: it routes through `langchain-anthropic`, `langchain-openai`, and others at runtime based on `config.provider.name`. Use `'*'` so that flags configured with `provider.name = "Anthropic"` or `"OpenAI"` are automatically handled without requiring a separate Anthropic or OpenAI handler. +Pass `{ providers: ['Bedrock'] }` to `createLangChainAgentsHandler`, `langchainAgents`, or `langchainGraph` to accept only those `config.provider.name` values. Names must match the config exactly. `langchainGraph` forwards `providers` to the handler and not to `graph()`. Several scoped handlers can be registered together; the shortest list that contains the name wins. Omit `providers` to keep matching every provider. + ## Installation ```bash diff --git a/packages/langchain-agents/agents.md b/packages/langchain-agents/agents.md index bbddfea..5e99e29 100644 --- a/packages/langchain-agents/agents.md +++ b/packages/langchain-agents/agents.md @@ -12,7 +12,7 @@ This is a **Tier 1 handler package**. It wraps LangGraph's `createReactAgent` (` providesFor = ['*', 'agent'] ``` -The `'*'` wildcard means this handler acts as a fallback for any `meta.mode === "agent"` variation that has no more-specific (exact-provider-name) handler registered. LangChain is a framework adapter — not a provider itself — so it routes through `langchain-anthropic`, `langchain-openai`, or other `BaseChatModel` implementations at runtime by inspecting `config.provider.name`. Using `'*'` lets users keep their flag variations configured with their real provider name (`"Anthropic"`, `"OpenAI"`, etc.) without needing a native handler for each. +The `'*'` wildcard means this handler acts as a fallback for any `meta.mode === "agent"` variation that has no more-specific (exact-provider-name) handler registered. LangChain is a framework adapter — not a provider itself — so it routes through `langchain-anthropic`, `langchain-openai`, or other `BaseChatModel` implementations at runtime by inspecting `config.provider.name`. Using `'*'` lets users keep their flag variations configured with their real provider name (`"Anthropic"`, `"OpenAI"`, etc.) without needing a native handler for each. Pass `{ providers: ['Bedrock'] }` to `createLangChainAgentsHandler` or `langchainGraph` to accept only those names; an omitted list still matches every provider. > **Priority rule:** if the caller also registers an explicit provider handler (e.g. `['OpenAI', 'agent']`), that handler takes precedence over the wildcard for matching variations. diff --git a/packages/langchain-agents/src/__tests__/handler.test.ts b/packages/langchain-agents/src/__tests__/handler.test.ts index 95c81e6..33e9b16 100644 --- a/packages/langchain-agents/src/__tests__/handler.test.ts +++ b/packages/langchain-agents/src/__tests__/handler.test.ts @@ -181,6 +181,20 @@ describe('createLangChainAgentsHandler', () => { expect(createLangChainAgentsHandler({} as any).providesFor).toEqual(['*', 'agent']); }); + it('leaves providers unset by default', () => { + expect(createLangChainAgentsHandler({} as any).providers).toBeUndefined(); + }); + + it('scopes the wildcard to the given providers', () => { + const handler = createLangChainAgentsHandler({} as any, { providers: ['Bedrock'] }); + expect(handler.providesFor).toEqual(['*', 'agent']); + expect(handler.providers).toEqual(['Bedrock']); + }); + + it('rejects an empty provider list', () => { + expect(() => createLangChainAgentsHandler({} as any, { providers: [] })).toThrow(/providers/i); + }); + it('returns independent instances on multiple calls', () => { const llm = {} as any; expect(createLangChainAgentsHandler(llm)).not.toBe(createLangChainAgentsHandler(llm)); @@ -522,6 +536,19 @@ describe('createLangChainAgentsHandler', () => { ); }); + it('forwards providers onto the handler and not to config()', async () => { + const { langchainAgents } = await import('../handler.js'); + const { configInternal: config } = await import('@launchdarkly/ai-server'); + const ctx = { kind: 'user' as const, key: 'u' }; + await langchainAgents('flag', 'hello', ctx, { providers: ['Bedrock'] } as any); + expect(config).toHaveBeenCalledWith( + expect.objectContaining({ + handler: expect.objectContaining({ providesFor: ['*', 'agent'], providers: ['Bedrock'] }), + }), + ); + expect((config as ReturnType).mock.calls[0][0].providers).toBeUndefined(); + }); + // ── 1.8 Streaming ──────────────────────────────────────────────────────────── async function collectStream(gen: AsyncGenerator): Promise { @@ -942,6 +969,26 @@ describe('langchainGraph', () => { ); }); + it('forwards providers onto the handler and not to graph()', async () => { + vi.resetModules(); + const graphMock = vi.fn().mockReturnValue({ call: vi.fn() }); + vi.doMock('@launchdarkly/ai-server', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, graphInternal: graphMock, config: vi.fn().mockReturnValue({ invoke: vi.fn() }) }; + }); + const { langchainGraph } = await import('../graph.js'); + langchainGraph('graph-flag', { providers: ['Bedrock'] } as any); + expect(graphMock).toHaveBeenCalledWith( + 'graph-flag', + expect.objectContaining({ + handlers: expect.arrayContaining([ + expect.objectContaining({ providesFor: ['*', 'agent'], providers: ['Bedrock'] }), + ]), + }), + ); + expect(graphMock.mock.calls[0][1].providers).toBeUndefined(); + }); + it('forwards extra options (e.g. toolHandlers) to graph()', async () => { vi.resetModules(); const graphMock = vi.fn().mockReturnValue({ call: vi.fn() }); diff --git a/packages/langchain-agents/src/graph.ts b/packages/langchain-agents/src/graph.ts index db9a997..62b989c 100644 --- a/packages/langchain-agents/src/graph.ts +++ b/packages/langchain-agents/src/graph.ts @@ -8,7 +8,16 @@ import { LD_AI_PACKAGE_NAME, LD_AI_PACKAGE_VERSION } from './version.js'; * `graph(key, { ...options, handlers: [createLangChainAgentsHandler(llm)] })`. * Use the base `graph()` directly for multi-provider graphs. */ -export const langchainGraph = (key: string, options: Omit, llm?: BaseChatModel) => { +export const langchainGraph = ( + key: string, + options: Omit & { providers?: readonly string[] }, + llm?: BaseChatModel, +) => { reportUsage('langchain-agents.langchainGraph', LD_AI_PACKAGE_NAME, LD_AI_PACKAGE_VERSION); - return graphInternal(key, { ...options, handlers: [createLangChainAgentsHandlerInternal(llm)] }); + const { providers, ...graphOptions } = options; + const handler = + providers === undefined + ? createLangChainAgentsHandlerInternal(llm) + : createLangChainAgentsHandlerInternal(llm, { providers }); + return graphInternal(key, { ...graphOptions, handlers: [handler] }); }; diff --git a/packages/langchain-agents/src/handler.ts b/packages/langchain-agents/src/handler.ts index cf1d1d4..a448fb7 100644 --- a/packages/langchain-agents/src/handler.ts +++ b/packages/langchain-agents/src/handler.ts @@ -385,7 +385,7 @@ const toToolDefinitions = (configTools: Record | undefined): ToolD /** `llm` may be a chat model, or `(config) => model` so `model.parameters` can be applied unchanged. */ export function createLangChainAgentsHandler( llm?: LangChainModelSource, - options: ContentCaptureOptions = {}, + options: ContentCaptureOptions & { providers?: readonly string[] } = {}, ): ProviderHandler { reportUsage('langchain-agents.createLangChainAgentsHandler', LD_AI_PACKAGE_NAME, LD_AI_PACKAGE_VERSION); return createLangChainAgentsHandlerInternal(llm, options); @@ -394,7 +394,7 @@ export function createLangChainAgentsHandler( /** `createLangChainAgentsHandler` without the `$ld:ai:sdk:usage` report. Package-internal; not exported from the package index. */ export function createLangChainAgentsHandlerInternal( llm?: LangChainModelSource, - { captureContent = false }: ContentCaptureOptions = {}, + { captureContent = false, providers }: ContentCaptureOptions & { providers?: readonly string[] } = {}, ): ProviderHandler { return createHandlerInternal( ['*', 'agent'], @@ -562,6 +562,7 @@ export function createLangChainAgentsHandlerInternal( } }, captureContent, + providers, ); } @@ -569,16 +570,19 @@ export const langchainAgents = ( configKey: string, userInput: string, context: LDContext, - // Both `captureContent` and `variables` are lifted out of `options`: the first configures the - // handler, the second belongs to the invocation. Passing either through to `config()` drops it — - // which is how a `{{user_input}}` placeholder used to reach the model unsubstituted whenever a - // caller used one of these wrappers instead of `config().invoke()`. + // `captureContent` and `providers` configure the handler; `variables` belongs to the invocation. + // Passing any of them through to `config()` drops it — which is how a `{{user_input}}` placeholder + // used to reach the model unsubstituted whenever a caller used one of these wrappers instead of + // `config().invoke()`. { captureContent, + providers, variables, ...options }: Omit[0], 'handler' | 'key'> & ContentCaptureOptions & { + /** Provider names this wildcard accepts. Forwarded to the handler, not to `config`. */ + providers?: readonly string[]; /** Template variables for the config's prompt. Forwarded to `invoke`, not to `config`. */ variables?: Record; } = {}, @@ -587,6 +591,6 @@ export const langchainAgents = ( return configInternal({ ...options, key: configKey, - handler: createLangChainAgentsHandlerInternal(undefined, { captureContent }), + handler: createLangChainAgentsHandlerInternal(undefined, { captureContent, providers }), }).invoke(userInput, context, variables); }; diff --git a/packages/langchain-messages/README.md b/packages/langchain-messages/README.md index 1cd5999..5864528 100644 --- a/packages/langchain-messages/README.md +++ b/packages/langchain-messages/README.md @@ -4,6 +4,8 @@ LangChain handler for `@launchdarkly/ai-server` using **LangChain chat models** **`providesFor`:** `['*', 'messages']` — matches any flag variation where `meta.mode` is `"messages"` and no more-specific handler is registered. LangChain is a framework adapter, not a provider: it routes through `langchain-anthropic`, `langchain-openai`, and others at runtime based on `config.provider.name`. Use `'*'` so that flags configured with `provider.name = "Anthropic"` or `"OpenAI"` are automatically handled without requiring a separate Anthropic or OpenAI handler. +Pass `{ providers: ['Bedrock'] }` to `createLangChainHandler` (and to `langchainMessages`) to accept only those `config.provider.name` values. Names must match the config exactly. Several scoped handlers can be registered together; the shortest list that contains the name wins. Omit `providers` to keep matching every provider. + ## Installation ```bash diff --git a/packages/langchain-messages/agents.md b/packages/langchain-messages/agents.md index 5d753ad..161aed7 100644 --- a/packages/langchain-messages/agents.md +++ b/packages/langchain-messages/agents.md @@ -12,7 +12,7 @@ This is a **Tier 1 handler package**. It wraps LangChain chat models (`@langchai providesFor = ['*', 'messages'] ``` -The `'*'` wildcard means this handler acts as a fallback for any `meta.mode === "messages"` variation that has no more-specific (exact-provider-name) handler registered. LangChain is a framework adapter — not a provider itself — so it routes through `langchain-anthropic`, `langchain-openai`, or other `BaseChatModel` implementations at runtime by inspecting `config.provider.name`. Using `'*'` lets users keep their flag variations configured with their real provider name (`"Anthropic"`, `"OpenAI"`, etc.) without needing a native handler for each. +The `'*'` wildcard means this handler acts as a fallback for any `meta.mode === "messages"` variation that has no more-specific (exact-provider-name) handler registered. LangChain is a framework adapter — not a provider itself — so it routes through `langchain-anthropic`, `langchain-openai`, or other `BaseChatModel` implementations at runtime by inspecting `config.provider.name`. Using `'*'` lets users keep their flag variations configured with their real provider name (`"Anthropic"`, `"OpenAI"`, etc.) without needing a native handler for each. Pass `{ providers: ['Bedrock'] }` to `createLangChainHandler` to accept only those names; an omitted list still matches every provider. > **Priority rule:** if the caller also registers an explicit provider handler (e.g. `['OpenAI', 'messages']`), that handler takes precedence over the wildcard for matching variations. diff --git a/packages/langchain-messages/src/__tests__/handler.test.ts b/packages/langchain-messages/src/__tests__/handler.test.ts index 1c90975..ebabb5c 100644 --- a/packages/langchain-messages/src/__tests__/handler.test.ts +++ b/packages/langchain-messages/src/__tests__/handler.test.ts @@ -124,6 +124,20 @@ describe('createLangChainHandler', () => { expect(createLangChainHandler(makeMockLLM() as any).providesFor).toEqual(['*', 'messages']); }); + it('leaves providers unset by default', () => { + expect(createLangChainHandler(makeMockLLM() as any).providers).toBeUndefined(); + }); + + it('scopes the wildcard to the given providers', () => { + const handler = createLangChainHandler(makeMockLLM() as any, { providers: ['Bedrock', 'Anthropic'] }); + expect(handler.providesFor).toEqual(['*', 'messages']); + expect(handler.providers).toEqual(['Bedrock', 'Anthropic']); + }); + + it('rejects an empty provider list', () => { + expect(() => createLangChainHandler(makeMockLLM() as any, { providers: [] })).toThrow(/providers/i); + }); + it('returns independent instances on multiple calls', () => { const llm = makeMockLLM() as any; expect(createLangChainHandler(llm)).not.toBe(createLangChainHandler(llm)); @@ -739,6 +753,20 @@ describe('createLangChainHandler', () => { expect(mockInvoke).toHaveBeenCalledWith('hello', ctx, undefined); }); + it('forwards providers onto the handler and not to config()', async () => { + const { langchainMessages } = await import('../handler.js'); + const { configInternal: config } = await import('@launchdarkly/ai-server'); + const ctx = { kind: 'user' as const, key: 'u' }; + (config as any).mockReturnValue({ invoke: vi.fn().mockResolvedValue({ response: 'ok', usage: {} }) }); + await langchainMessages('flag', 'hello', ctx, { providers: ['Bedrock'] } as any); + expect(config).toHaveBeenCalledWith( + expect.objectContaining({ + handler: expect.objectContaining({ providesFor: ['*', 'messages'], providers: ['Bedrock'] }), + }), + ); + expect((config as any).mock.calls[0][0].providers).toBeUndefined(); + }); + // ── 1.8 Streaming ──────────────────────────────────────────────────────────── async function collectStream(gen: AsyncGenerator): Promise { diff --git a/packages/langchain-messages/src/handler.ts b/packages/langchain-messages/src/handler.ts index 76d5dc8..03f611b 100644 --- a/packages/langchain-messages/src/handler.ts +++ b/packages/langchain-messages/src/handler.ts @@ -331,7 +331,7 @@ const assistantOutput = (content: unknown, toolCalls: ReadonlyArray | u /** `llm` may be a chat model, or `(config) => model` so `model.parameters` can be applied unchanged. */ export function createLangChainHandler( llm?: LangChainModelSource, - options: ContentCaptureOptions = {}, + options: ContentCaptureOptions & { providers?: readonly string[] } = {}, ): ProviderHandler { reportUsage('langchain-messages.createLangChainHandler', LD_AI_PACKAGE_NAME, LD_AI_PACKAGE_VERSION); return createLangChainHandlerInternal(llm, options); @@ -339,7 +339,7 @@ export function createLangChainHandler( function createLangChainHandlerInternal( llm?: LangChainModelSource, - { captureContent = false }: ContentCaptureOptions = {}, + { captureContent = false, providers }: ContentCaptureOptions & { providers?: readonly string[] } = {}, ): ProviderHandler { const MAX_STEPS = 10; @@ -737,6 +737,7 @@ function createLangChainHandlerInternal( } }, captureContent, + providers, ); } @@ -744,16 +745,19 @@ export const langchainMessages = ( configKey: string, userInput: string, context: LDContext, - // Both `captureContent` and `variables` are lifted out of `options`: the first configures the - // handler, the second belongs to the invocation. Passing either through to `config()` drops it — - // which is how a `{{user_input}}` placeholder used to reach the model unsubstituted whenever a - // caller used one of these wrappers instead of `config().invoke()`. + // `captureContent` and `providers` configure the handler; `variables` belongs to the invocation. + // Passing any of them through to `config()` drops it — which is how a `{{user_input}}` placeholder + // used to reach the model unsubstituted whenever a caller used one of these wrappers instead of + // `config().invoke()`. { captureContent, + providers, variables, ...options }: Omit[0], 'handler' | 'key'> & ContentCaptureOptions & { + /** Provider names this wildcard accepts. Forwarded to the handler, not to `config`. */ + providers?: readonly string[]; /** Template variables for the config's prompt. Forwarded to `invoke`, not to `config`. */ variables?: Record; } = {}, @@ -762,6 +766,6 @@ export const langchainMessages = ( return configInternal({ ...options, key: configKey, - handler: createLangChainHandlerInternal(undefined, { captureContent }), + handler: createLangChainHandlerInternal(undefined, { captureContent, providers }), }).invoke(userInput, context, variables); };