@datacules/agent-identity-langchain 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/langchain.test.ts +179 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datacules/agent-identity-langchain",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "LangChain and LangGraph integration for @datacules/agent-identity",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"type-check": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
|
-
"@datacules/agent-identity": "^0.
|
|
14
|
+
"@datacules/agent-identity": "^0.6.0",
|
|
15
15
|
"@langchain/core": ">=0.2.0"
|
|
16
16
|
},
|
|
17
17
|
"peerDependenciesMeta": {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* langchain.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Vitest unit tests for @datacules/agent-identity-langchain.
|
|
5
|
+
*
|
|
6
|
+
* Covers createAgentIdentityModel, AgentIdentityCallbackHandler,
|
|
7
|
+
* and createAgentIdentityNode.
|
|
8
|
+
*
|
|
9
|
+
* @langchain/core/callbacks/base is mocked via vi.mock() factory.
|
|
10
|
+
* vi.mock() calls are hoisted to the top of the module by vitest so
|
|
11
|
+
* the mock is in place before the source file is imported. This means
|
|
12
|
+
* the tests work whether or not @langchain/core is installed in the
|
|
13
|
+
* root node_modules.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
16
|
+
|
|
17
|
+
// ─── Mock @langchain/core ─────────────────────────────────────────────────────
|
|
18
|
+
// Must be declared before the import of ./index because vi.mock() is hoisted.
|
|
19
|
+
vi.mock('@langchain/core/callbacks/base', () => ({
|
|
20
|
+
BaseCallbackHandler: class MockBaseCallbackHandler {
|
|
21
|
+
name: string = '';
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
createAgentIdentityModel,
|
|
27
|
+
AgentIdentityCallbackHandler,
|
|
28
|
+
createAgentIdentityNode,
|
|
29
|
+
} from './index';
|
|
30
|
+
import type { AgentRequestContext, Credential, RoutingRule } from '@datacules/agent-identity';
|
|
31
|
+
|
|
32
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const credentials: Credential[] = [
|
|
35
|
+
{
|
|
36
|
+
id: 'cred-openai',
|
|
37
|
+
kind: 'fixed',
|
|
38
|
+
name: 'OpenAI prod',
|
|
39
|
+
scope: 'openai:all',
|
|
40
|
+
status: 'active',
|
|
41
|
+
provider: 'openai',
|
|
42
|
+
ref: 'openai-prod-slot',
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const rules: RoutingRule[] = [
|
|
47
|
+
{
|
|
48
|
+
id: 'rule-openai',
|
|
49
|
+
description: 'Route all OpenAI requests',
|
|
50
|
+
credentialRef: 'openai-prod-slot',
|
|
51
|
+
credentialKind: 'fixed',
|
|
52
|
+
priority: 10,
|
|
53
|
+
matchProvider: 'openai',
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// Empty rules — no credential will resolve for any context
|
|
58
|
+
const rulesNoMatch: RoutingRule[] = [];
|
|
59
|
+
|
|
60
|
+
function makeCtx(overrides: Partial<AgentRequestContext> = {}): AgentRequestContext {
|
|
61
|
+
return {
|
|
62
|
+
userId: 'user-alice',
|
|
63
|
+
resourceId: 'res-001',
|
|
64
|
+
resourceKind: 'personal',
|
|
65
|
+
provider: 'openai',
|
|
66
|
+
model: 'gpt-4o',
|
|
67
|
+
action: 'read',
|
|
68
|
+
traceId: 'trace-abc',
|
|
69
|
+
requestedAt: new Date().toISOString(),
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── createAgentIdentityModel ─────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe('createAgentIdentityModel', () => {
|
|
77
|
+
it('returns resolved credential metadata', () => {
|
|
78
|
+
const fetchSecret = vi.fn(async () => 'sk-secret');
|
|
79
|
+
const result = createAgentIdentityModel(makeCtx(), { credentials, rules, fetchSecret });
|
|
80
|
+
expect(result.resolved).toBeDefined();
|
|
81
|
+
expect(result.resolved.ref).toBe('openai-prod-slot');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('resolved has correct credentialId and resolvedFor', () => {
|
|
85
|
+
const fetchSecret = vi.fn(async () => 'sk-secret');
|
|
86
|
+
const { resolved } = createAgentIdentityModel(makeCtx(), { credentials, rules, fetchSecret });
|
|
87
|
+
expect(resolved.credentialId).toBe('cred-openai');
|
|
88
|
+
// kind=fixed → resolvedFor is 'service'
|
|
89
|
+
expect(resolved.resolvedFor).toBe('service');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns a getModel function', () => {
|
|
93
|
+
const fetchSecret = vi.fn(async () => 'sk-secret');
|
|
94
|
+
const { getModel } = createAgentIdentityModel(makeCtx(), { credentials, rules, fetchSecret });
|
|
95
|
+
expect(typeof getModel).toBe('function');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('throws when no routing rule matches the context', () => {
|
|
99
|
+
const fetchSecret = vi.fn(async () => 'sk-secret');
|
|
100
|
+
expect(() =>
|
|
101
|
+
createAgentIdentityModel(makeCtx(), { credentials, rules: rulesNoMatch, fetchSecret })
|
|
102
|
+
).toThrow('[agent-identity] No credential resolved');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('getModel() calls fetchSecret with the resolved ref then throws for unsupported provider', async () => {
|
|
106
|
+
const fetchSecret = vi.fn(async () => 'sk-secret');
|
|
107
|
+
// Use 'local' provider — it matches a rule but has no provider adapter in getModel()
|
|
108
|
+
const localCredential: Credential = { ...credentials[0], provider: 'local', ref: 'local-slot' };
|
|
109
|
+
const localRule: RoutingRule = { ...rules[0], credentialRef: 'local-slot', matchProvider: 'local' };
|
|
110
|
+
const { getModel } = createAgentIdentityModel(
|
|
111
|
+
makeCtx({ provider: 'local' }),
|
|
112
|
+
{ credentials: [localCredential], rules: [localRule], fetchSecret }
|
|
113
|
+
);
|
|
114
|
+
// getModel() calls fetchSecret first, then throws because 'local' is unsupported
|
|
115
|
+
await expect(getModel()).rejects.toThrow('not yet supported');
|
|
116
|
+
expect(fetchSecret).toHaveBeenCalledWith('local-slot');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ─── AgentIdentityCallbackHandler ────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
describe('AgentIdentityCallbackHandler', () => {
|
|
123
|
+
const resolved = {
|
|
124
|
+
credentialId: 'cred-openai',
|
|
125
|
+
kind: 'fixed' as const,
|
|
126
|
+
ref: 'openai-prod-slot',
|
|
127
|
+
resolvedFor: 'service',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
it('instantiates without errors', () => {
|
|
131
|
+
expect(() => new AgentIdentityCallbackHandler(resolved)).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('handleLLMStart attaches agentIdentity metadata to extraParams', async () => {
|
|
135
|
+
const handler = new AgentIdentityCallbackHandler(resolved);
|
|
136
|
+
const extraParams: Record<string, unknown> = {};
|
|
137
|
+
// Pass extraParams as the 5th argument; _llm and _prompts are unused
|
|
138
|
+
await handler.handleLLMStart({} as never, [], 'run-001', undefined, extraParams);
|
|
139
|
+
expect(extraParams['agentIdentityCredentialId']).toBe('cred-openai');
|
|
140
|
+
expect(extraParams['agentIdentityResolvedFor']).toBe('service');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('handleLLMEnd resolves without throwing', async () => {
|
|
144
|
+
const handler = new AgentIdentityCallbackHandler(resolved);
|
|
145
|
+
await expect(handler.handleLLMEnd({} as never, 'run-001')).resolves.toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ─── createAgentIdentityNode ──────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
describe('createAgentIdentityNode', () => {
|
|
152
|
+
it('injects resolvedCredential into state', async () => {
|
|
153
|
+
const node = createAgentIdentityNode(credentials, rules);
|
|
154
|
+
const state = { agentContext: makeCtx() };
|
|
155
|
+
const result = await node(state);
|
|
156
|
+
expect(result.resolvedCredential).toBeDefined();
|
|
157
|
+
expect((result.resolvedCredential as { ref: string }).ref).toBe('openai-prod-slot');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('preserves all existing state properties alongside resolvedCredential', async () => {
|
|
161
|
+
const node = createAgentIdentityNode(credentials, rules);
|
|
162
|
+
const state = { agentContext: makeCtx(), sessionId: 'sess-xyz', count: 42 };
|
|
163
|
+
const result = await node(state);
|
|
164
|
+
expect(result.sessionId).toBe('sess-xyz');
|
|
165
|
+
expect(result.count).toBe(42);
|
|
166
|
+
expect(result.agentContext).toStrictEqual(state.agentContext);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('throws when state.agentContext is missing', async () => {
|
|
170
|
+
const node = createAgentIdentityNode(credentials, rules);
|
|
171
|
+
await expect(node({ otherField: true })).rejects.toThrow('state.agentContext is required');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('throws when no credential resolves for the context', async () => {
|
|
175
|
+
const node = createAgentIdentityNode(credentials, rulesNoMatch);
|
|
176
|
+
const state = { agentContext: makeCtx() };
|
|
177
|
+
await expect(node(state)).rejects.toThrow('No credential resolved');
|
|
178
|
+
});
|
|
179
|
+
});
|