@nebutra/agents 1.1.1 → 1.1.2
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/LICENSE +21 -676
- package/README.md +7 -3
- package/dist/{agent-CahnMASx.d.ts → agent-DDGWuUpe.d.ts} +1 -1
- package/{src/env.ts → dist/chunk-BMSL4E4A.js} +23 -47
- package/dist/{chunk-5JZJ5KMC.js → chunk-BZKIMAGK.js} +16 -7
- package/dist/{chunk-NPQECBXL.js → chunk-GRSMUTUS.js} +60 -7
- package/dist/{chunk-B7XWL35G.js → chunk-QYZDHC5A.js} +5 -1
- package/dist/{chunk-RLWM437Q.js → chunk-RNFUEMUB.js} +10 -3
- package/dist/chunk-RWQL4HXC.js +171 -0
- package/dist/{chunk-NVPE5EDI.js → chunk-V6VC2O6Q.js} +4 -3
- package/dist/chunk-VZPQOXWW.js +47 -0
- package/dist/{chunk-5LX742GP.js → chunk-XLBS3XUI.js} +4 -50
- package/dist/env.d.ts +42 -0
- package/dist/env.js +12 -0
- package/dist/fallback.d.ts +100 -0
- package/dist/fallback.js +18 -0
- package/dist/generation/index.d.ts +122 -0
- package/dist/generation/index.js +19 -0
- package/dist/index.d.ts +21 -328
- package/dist/index.js +57 -245
- package/dist/observability.d.ts +46 -0
- package/dist/observability.js +13 -0
- package/dist/providers/langchain.d.ts +2 -2
- package/dist/providers/vercel-ai.d.ts +2 -2
- package/dist/providers/vercel-ai.js +15 -7
- package/dist/sdk/config.d.ts +3 -0
- package/dist/sdk/config.js +1 -1
- package/dist/sdk/index.d.ts +36 -3
- package/dist/sdk/index.js +20 -7
- package/dist/sdk/models.d.ts +18 -6
- package/dist/sdk/models.js +1 -1
- package/dist/sdk/provider.d.ts +1 -0
- package/dist/sdk/provider.js +3 -3
- package/dist/tools.d.ts +1 -1
- package/dist/{types-NtgB3pch.d.ts → types-BytC-HfQ.d.ts} +1 -5
- package/package.json +71 -19
- package/.turbo/turbo-build.log +0 -40
- package/.turbo/turbo-test.log +0 -19
- package/.turbo/turbo-typecheck.log +0 -4
- package/AGENTS.md +0 -63
- package/CHANGELOG.md +0 -36
- package/src/__tests__/cost-observability.test.ts +0 -172
- package/src/__tests__/fallback-wiring.test.ts +0 -313
- package/src/__tests__/generation.test.ts +0 -111
- package/src/__tests__/public-api.test.ts +0 -114
- package/src/__tests__/runtime-gateway.test.ts +0 -108
- package/src/agent.ts +0 -117
- package/src/context.ts +0 -99
- package/src/fallback.ts +0 -358
- package/src/gateway.ts +0 -234
- package/src/generation/index.ts +0 -157
- package/src/generation/mock-provider.ts +0 -123
- package/src/generation/types.ts +0 -87
- package/src/index.ts +0 -104
- package/src/memory.ts +0 -126
- package/src/observability.ts +0 -102
- package/src/orchestrator.ts +0 -147
- package/src/providers/langchain.ts +0 -28
- package/src/providers/vercel-ai.ts +0 -114
- package/src/router.ts +0 -158
- package/src/sdk/config.ts +0 -73
- package/src/sdk/index.ts +0 -214
- package/src/sdk/models.ts +0 -57
- package/src/sdk/provider.ts +0 -80
- package/src/tenant.ts +0 -52
- package/src/tools.ts +0 -65
- package/src/types.ts +0 -114
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -21
|
@@ -1,313 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fallback wiring — verifies that the multi-provider fallback chain is
|
|
3
|
-
* actually integrated into:
|
|
4
|
-
*
|
|
5
|
-
* 1. `VercelAIAgent.execute()` — chat path via `streamText`
|
|
6
|
-
* 2. `embed()` / `embedMany()` — embedding path
|
|
7
|
-
*
|
|
8
|
-
* The AI SDK + provider SDKs are mocked so no network traffic occurs.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
-
|
|
13
|
-
import { _resetAgentsEnvCache } from "../env";
|
|
14
|
-
import { filterAvailableProviders, runEmbedWithFallback, runWithFallback } from "../fallback";
|
|
15
|
-
|
|
16
|
-
// ─── Mock the dynamically-imported provider SDKs ─────────────────────────────
|
|
17
|
-
// We mock at the module level so any `await import("@ai-sdk/...")` inside
|
|
18
|
-
// `buildModel()` / `buildEmbeddingModel()` returns a deterministic stub.
|
|
19
|
-
|
|
20
|
-
vi.mock("@openrouter/ai-sdk-provider", () => ({
|
|
21
|
-
createOpenRouter: vi.fn(() => ({
|
|
22
|
-
chat: vi.fn((id: string) => ({ __provider: "openrouter", __id: id })),
|
|
23
|
-
textEmbeddingModel: vi.fn((id: string) => ({
|
|
24
|
-
__provider: "openrouter",
|
|
25
|
-
__id: id,
|
|
26
|
-
__kind: "embedding",
|
|
27
|
-
})),
|
|
28
|
-
})),
|
|
29
|
-
}));
|
|
30
|
-
|
|
31
|
-
vi.mock("@ai-sdk/anthropic", () => ({
|
|
32
|
-
createAnthropic: vi.fn(() =>
|
|
33
|
-
Object.assign((id: string) => ({ __provider: "anthropic", __id: id }), {
|
|
34
|
-
__provider: "anthropic",
|
|
35
|
-
}),
|
|
36
|
-
),
|
|
37
|
-
}));
|
|
38
|
-
|
|
39
|
-
vi.mock("@ai-sdk/openai", () => ({
|
|
40
|
-
createOpenAI: vi.fn(() =>
|
|
41
|
-
Object.assign((id: string) => ({ __provider: "openai", __id: id }), {
|
|
42
|
-
__provider: "openai",
|
|
43
|
-
textEmbeddingModel: vi.fn((id: string) => ({
|
|
44
|
-
__provider: "openai",
|
|
45
|
-
__id: id,
|
|
46
|
-
__kind: "embedding",
|
|
47
|
-
})),
|
|
48
|
-
}),
|
|
49
|
-
),
|
|
50
|
-
}));
|
|
51
|
-
|
|
52
|
-
// ─── filterAvailableProviders ───────────────────────────────────────────────
|
|
53
|
-
|
|
54
|
-
describe("filterAvailableProviders()", () => {
|
|
55
|
-
beforeEach(() => {
|
|
56
|
-
vi.unstubAllEnvs();
|
|
57
|
-
_resetAgentsEnvCache();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
afterEach(() => {
|
|
61
|
-
vi.unstubAllEnvs();
|
|
62
|
-
_resetAgentsEnvCache();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("filters out providers whose API key is missing", () => {
|
|
66
|
-
vi.stubEnv("OPENROUTER_API_KEY", "or-key");
|
|
67
|
-
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
|
68
|
-
vi.stubEnv("OPENAI_API_KEY", "");
|
|
69
|
-
|
|
70
|
-
const out = filterAvailableProviders(["openrouter", "anthropic", "openai"]);
|
|
71
|
-
expect(out).toEqual(["openrouter"]);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("returns the original chain when NO providers have keys (avoid empty)", () => {
|
|
75
|
-
vi.stubEnv("OPENROUTER_API_KEY", "");
|
|
76
|
-
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
|
77
|
-
vi.stubEnv("OPENAI_API_KEY", "");
|
|
78
|
-
|
|
79
|
-
const out = filterAvailableProviders(["openrouter", "anthropic"]);
|
|
80
|
-
expect(out).toEqual(["openrouter", "anthropic"]);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// ─── Single-provider config: backward compatibility ──────────────────────────
|
|
85
|
-
|
|
86
|
-
describe("runWithFallback — single-provider config", () => {
|
|
87
|
-
beforeEach(() => {
|
|
88
|
-
vi.unstubAllEnvs();
|
|
89
|
-
_resetAgentsEnvCache();
|
|
90
|
-
vi.stubEnv("OPENROUTER_API_KEY", "or-key");
|
|
91
|
-
// Anthropic + OpenAI deliberately missing — chain should be filtered
|
|
92
|
-
// down to just openrouter.
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
afterEach(() => {
|
|
96
|
-
vi.unstubAllEnvs();
|
|
97
|
-
_resetAgentsEnvCache();
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("does not rotate when only one provider key is present", async () => {
|
|
101
|
-
let calls = 0;
|
|
102
|
-
const result = await runWithFallback(
|
|
103
|
-
async () => {
|
|
104
|
-
calls += 1;
|
|
105
|
-
return "ok" as const;
|
|
106
|
-
},
|
|
107
|
-
{ chain: ["openrouter", "anthropic", "openai"] },
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
expect(result.result).toBe("ok");
|
|
111
|
-
expect(result.attempts).toBe(1);
|
|
112
|
-
expect(result.provider).toBe("openrouter");
|
|
113
|
-
expect(calls).toBe(1);
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// ─── Embedding fallback rotation ─────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
describe("runEmbedWithFallback()", () => {
|
|
120
|
-
beforeEach(() => {
|
|
121
|
-
vi.unstubAllEnvs();
|
|
122
|
-
_resetAgentsEnvCache();
|
|
123
|
-
vi.stubEnv("OPENROUTER_API_KEY", "or-key");
|
|
124
|
-
vi.stubEnv("OPENAI_API_KEY", "oa-key");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
afterEach(() => {
|
|
128
|
-
vi.unstubAllEnvs();
|
|
129
|
-
_resetAgentsEnvCache();
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("rotates to the next embedding provider on retryable error", async () => {
|
|
133
|
-
let calls = 0;
|
|
134
|
-
const result = await runEmbedWithFallback(
|
|
135
|
-
async () => {
|
|
136
|
-
calls += 1;
|
|
137
|
-
if (calls === 1) {
|
|
138
|
-
throw Object.assign(new Error("Rate Limited"), { statusCode: 429 });
|
|
139
|
-
}
|
|
140
|
-
return { embeddings: [[0.1, 0.2]] } as const;
|
|
141
|
-
},
|
|
142
|
-
{ chain: ["openrouter", "openai"] },
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
expect(calls).toBe(2);
|
|
146
|
-
expect(result.attempts).toBe(2);
|
|
147
|
-
expect(result.provider).toBe("openai");
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("excludes anthropic from the embedding chain (no embedding API)", async () => {
|
|
151
|
-
vi.stubEnv("ANTHROPIC_API_KEY", "an-key");
|
|
152
|
-
|
|
153
|
-
const seenProviders: string[] = [];
|
|
154
|
-
let calls = 0;
|
|
155
|
-
|
|
156
|
-
await expect(
|
|
157
|
-
runEmbedWithFallback(
|
|
158
|
-
async () => {
|
|
159
|
-
calls += 1;
|
|
160
|
-
// Capture which provider key was used by checking the env state
|
|
161
|
-
// via the call ordering — we don't have direct access here, so we
|
|
162
|
-
// just throw retryable to force walking the chain.
|
|
163
|
-
throw Object.assign(new Error("503"), { statusCode: 503 });
|
|
164
|
-
},
|
|
165
|
-
// Even if the user includes anthropic, it's filtered out:
|
|
166
|
-
{ chain: ["anthropic", "openrouter", "openai"] },
|
|
167
|
-
),
|
|
168
|
-
).rejects.toThrow(/All embedding providers/);
|
|
169
|
-
|
|
170
|
-
// Should only attempt openrouter + openai (anthropic filtered out)
|
|
171
|
-
expect(calls).toBe(2);
|
|
172
|
-
void seenProviders;
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("throws a clear error when no embedding-capable provider has a key", async () => {
|
|
176
|
-
vi.unstubAllEnvs();
|
|
177
|
-
_resetAgentsEnvCache();
|
|
178
|
-
vi.stubEnv("ANTHROPIC_API_KEY", "an-key");
|
|
179
|
-
// No openrouter or openai key
|
|
180
|
-
|
|
181
|
-
await expect(
|
|
182
|
-
runEmbedWithFallback(async () => ({ ok: true }), {
|
|
183
|
-
chain: ["openrouter", "openai", "anthropic"],
|
|
184
|
-
}),
|
|
185
|
-
).rejects.toThrow(/No embedding-capable providers available/);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("rethrows non-retryable errors immediately", async () => {
|
|
189
|
-
let calls = 0;
|
|
190
|
-
await expect(
|
|
191
|
-
runEmbedWithFallback(
|
|
192
|
-
async () => {
|
|
193
|
-
calls += 1;
|
|
194
|
-
throw Object.assign(new Error("Bad Request"), { statusCode: 400 });
|
|
195
|
-
},
|
|
196
|
-
{ chain: ["openrouter", "openai"] },
|
|
197
|
-
),
|
|
198
|
-
).rejects.toThrow(/Bad Request/);
|
|
199
|
-
expect(calls).toBe(1);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
// ─── VercelAIAgent.execute() rotates providers on retryable error ────────────
|
|
204
|
-
|
|
205
|
-
describe("VercelAIAgent.execute() — fallback wiring", () => {
|
|
206
|
-
beforeEach(() => {
|
|
207
|
-
vi.unstubAllEnvs();
|
|
208
|
-
_resetAgentsEnvCache();
|
|
209
|
-
vi.resetModules();
|
|
210
|
-
vi.stubEnv("OPENROUTER_API_KEY", "or-key");
|
|
211
|
-
vi.stubEnv("ANTHROPIC_API_KEY", "an-key");
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
afterEach(() => {
|
|
215
|
-
vi.unstubAllEnvs();
|
|
216
|
-
_resetAgentsEnvCache();
|
|
217
|
-
vi.resetModules();
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it("rotates to the next provider when streamText fails with a retryable error", async () => {
|
|
221
|
-
let streamCalls = 0;
|
|
222
|
-
|
|
223
|
-
// Dynamic mock of `ai` — use vi.doMock so it applies to subsequent imports.
|
|
224
|
-
vi.doMock("ai", () => ({
|
|
225
|
-
streamText: vi.fn(() => {
|
|
226
|
-
streamCalls += 1;
|
|
227
|
-
if (streamCalls === 1) {
|
|
228
|
-
// Return an object whose .text promise rejects with a retryable error
|
|
229
|
-
return {
|
|
230
|
-
text: Promise.reject(
|
|
231
|
-
Object.assign(new Error("503 Service Unavailable"), {
|
|
232
|
-
statusCode: 503,
|
|
233
|
-
}),
|
|
234
|
-
),
|
|
235
|
-
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
return {
|
|
239
|
-
text: Promise.resolve("hello from fallback"),
|
|
240
|
-
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
|
241
|
-
};
|
|
242
|
-
}),
|
|
243
|
-
stepCountIs: vi.fn((n: number) => n),
|
|
244
|
-
dynamicTool: vi.fn((d: unknown) => d),
|
|
245
|
-
}));
|
|
246
|
-
|
|
247
|
-
// Avoid real billing/credit deduction
|
|
248
|
-
vi.doMock("@nebutra/billing/credits", () => ({
|
|
249
|
-
deductCredits: vi.fn(async () => {}),
|
|
250
|
-
}));
|
|
251
|
-
|
|
252
|
-
const { VercelAIAgent } = await import("../providers/vercel-ai");
|
|
253
|
-
const agent = new VercelAIAgent({
|
|
254
|
-
id: "test",
|
|
255
|
-
name: "Test",
|
|
256
|
-
description: "",
|
|
257
|
-
model: "flagship",
|
|
258
|
-
instructions: "be helpful",
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
const response = await agent.run([{ role: "user", content: "hi", timestamp: new Date() }], {
|
|
262
|
-
tenantId: "org_1",
|
|
263
|
-
userId: "u_1",
|
|
264
|
-
conversationId: "c_1",
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
expect(streamCalls).toBe(2);
|
|
268
|
-
expect(response.messages.at(-1)?.content).toBe("hello from fallback");
|
|
269
|
-
expect(response.usage.totalTokens).toBe(15);
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it("does not rotate when the only configured provider succeeds (single-provider deploy)", async () => {
|
|
273
|
-
vi.unstubAllEnvs();
|
|
274
|
-
_resetAgentsEnvCache();
|
|
275
|
-
vi.resetModules();
|
|
276
|
-
vi.stubEnv("OPENROUTER_API_KEY", "or-key");
|
|
277
|
-
// Anthropic + OpenAI deliberately absent
|
|
278
|
-
|
|
279
|
-
let streamCalls = 0;
|
|
280
|
-
vi.doMock("ai", () => ({
|
|
281
|
-
streamText: vi.fn(() => {
|
|
282
|
-
streamCalls += 1;
|
|
283
|
-
return {
|
|
284
|
-
text: Promise.resolve("ok"),
|
|
285
|
-
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }),
|
|
286
|
-
};
|
|
287
|
-
}),
|
|
288
|
-
stepCountIs: vi.fn((n: number) => n),
|
|
289
|
-
dynamicTool: vi.fn((d: unknown) => d),
|
|
290
|
-
}));
|
|
291
|
-
vi.doMock("@nebutra/billing/credits", () => ({
|
|
292
|
-
deductCredits: vi.fn(async () => {}),
|
|
293
|
-
}));
|
|
294
|
-
|
|
295
|
-
const { VercelAIAgent } = await import("../providers/vercel-ai");
|
|
296
|
-
const agent = new VercelAIAgent({
|
|
297
|
-
id: "single",
|
|
298
|
-
name: "Single",
|
|
299
|
-
description: "",
|
|
300
|
-
model: "flagship",
|
|
301
|
-
instructions: "x",
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
const response = await agent.run([{ role: "user", content: "ping", timestamp: new Date() }], {
|
|
305
|
-
tenantId: "t",
|
|
306
|
-
userId: "u",
|
|
307
|
-
conversationId: "c",
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
expect(streamCalls).toBe(1);
|
|
311
|
-
expect(response.messages.at(-1)?.content).toBe("ok");
|
|
312
|
-
});
|
|
313
|
-
});
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Image / video generation modality.
|
|
3
|
-
*
|
|
4
|
-
* Verifies the env-key-gated provider chain mirrors the LLM fallback design:
|
|
5
|
-
* deterministic mock terminal, additive real-provider priority, retryable
|
|
6
|
-
* rotation, and tenant-scoped attribution.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
10
|
-
import {
|
|
11
|
-
_resetGenerationRegistry,
|
|
12
|
-
type GenerationContext,
|
|
13
|
-
type GenerationProvider,
|
|
14
|
-
generateImage,
|
|
15
|
-
generateVideo,
|
|
16
|
-
listGenerationProviders,
|
|
17
|
-
registerGenerationProvider,
|
|
18
|
-
} from "../generation/index";
|
|
19
|
-
|
|
20
|
-
const ctx: GenerationContext = {
|
|
21
|
-
tenantId: "org_test",
|
|
22
|
-
userId: "user_test",
|
|
23
|
-
conversationId: "canvas_1",
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
beforeEach(() => {
|
|
27
|
-
_resetGenerationRegistry();
|
|
28
|
-
delete process.env.GENERATION_FALLBACK_CHAIN;
|
|
29
|
-
});
|
|
30
|
-
afterEach(() => {
|
|
31
|
-
_resetGenerationRegistry();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
describe("mock provider (terminal)", () => {
|
|
35
|
-
it("is always available with no secret", () => {
|
|
36
|
-
expect(listGenerationProviders("image")).toEqual(["mock"]);
|
|
37
|
-
expect(listGenerationProviders("video")).toEqual(["mock"]);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("produces a deterministic data URI for identical input", async () => {
|
|
41
|
-
const a = await generateImage({ prompt: "a blue fox", width: 512, height: 512 }, ctx);
|
|
42
|
-
const b = await generateImage({ prompt: "a blue fox", width: 512, height: 512 }, ctx);
|
|
43
|
-
expect(a.url).toBe(b.url);
|
|
44
|
-
expect(a.url.startsWith("data:image/svg+xml;base64,")).toBe(true);
|
|
45
|
-
expect(a.width).toBe(512);
|
|
46
|
-
expect(a.providerName).toBe("mock");
|
|
47
|
-
expect(a.usage.units).toBe(1);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("varies output by prompt", async () => {
|
|
51
|
-
const a = await generateImage({ prompt: "sunset" }, ctx);
|
|
52
|
-
const b = await generateImage({ prompt: "moonrise" }, ctx);
|
|
53
|
-
expect(a.url).not.toBe(b.url);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("returns a poster frame + second-based units for video", async () => {
|
|
57
|
-
const v = await generateVideo({ prompt: "waves", durationSeconds: 8 }, ctx);
|
|
58
|
-
expect(v.modality).toBe("video");
|
|
59
|
-
expect(v.usage.units).toBe(8);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe("env-key gating + priority", () => {
|
|
64
|
-
const realProvider: GenerationProvider = {
|
|
65
|
-
name: "fake-real",
|
|
66
|
-
envKey: "FAKE_REAL_API_KEY",
|
|
67
|
-
capabilities: ["image"],
|
|
68
|
-
async generateImage(req) {
|
|
69
|
-
return {
|
|
70
|
-
modality: "image",
|
|
71
|
-
mimeType: "image/png",
|
|
72
|
-
url: "https://example.test/real.png",
|
|
73
|
-
width: req.width ?? 1024,
|
|
74
|
-
height: req.height ?? 1024,
|
|
75
|
-
providerName: "fake-real",
|
|
76
|
-
model: "fake-real-1",
|
|
77
|
-
usage: { units: 1 },
|
|
78
|
-
};
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
it("hides a provider whose env key is absent", () => {
|
|
83
|
-
registerGenerationProvider(realProvider);
|
|
84
|
-
delete process.env.FAKE_REAL_API_KEY;
|
|
85
|
-
expect(listGenerationProviders("image")).toEqual(["mock"]);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("prefers the real provider when its key is present", async () => {
|
|
89
|
-
registerGenerationProvider(realProvider);
|
|
90
|
-
process.env.FAKE_REAL_API_KEY = "x";
|
|
91
|
-
expect(listGenerationProviders("image")).toEqual(["fake-real", "mock"]);
|
|
92
|
-
const r = await generateImage({ prompt: "hi" }, ctx);
|
|
93
|
-
expect(r.providerName).toBe("fake-real");
|
|
94
|
-
delete process.env.FAKE_REAL_API_KEY;
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe("retryable rotation", () => {
|
|
99
|
-
it("rotates to mock when a real provider throws a retryable error", async () => {
|
|
100
|
-
registerGenerationProvider({
|
|
101
|
-
name: "flaky",
|
|
102
|
-
envKey: null,
|
|
103
|
-
capabilities: ["image"],
|
|
104
|
-
async generateImage() {
|
|
105
|
-
throw new Error("429 Too Many Requests");
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
const r = await generateImage({ prompt: "resilient" }, ctx);
|
|
109
|
-
expect(r.providerName).toBe("mock");
|
|
110
|
-
});
|
|
111
|
-
});
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public API contract for @nebutra/agents.
|
|
3
|
-
*
|
|
4
|
-
* Guards the surface area that was consolidated from `@nebutra/ai-sdk`
|
|
5
|
-
* (generateText / streamText / embed / embedMany / configure / models / createModel)
|
|
6
|
-
* plus the pre-existing agent orchestration primitives. Any deletion or
|
|
7
|
-
* rename to these exports is a breaking change.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { describe, expect, it } from "vitest";
|
|
11
|
-
import * as agents from "../index";
|
|
12
|
-
|
|
13
|
-
describe("@nebutra/agents public API contract", () => {
|
|
14
|
-
// ─── Agent orchestration primitives ──────────────────────────────────────
|
|
15
|
-
it("exports BaseAgent class", () => {
|
|
16
|
-
expect(agents.BaseAgent).toBeDefined();
|
|
17
|
-
expect(typeof agents.BaseAgent).toBe("function");
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("exports AgentOrchestrator class", () => {
|
|
21
|
-
expect(agents.AgentOrchestrator).toBeDefined();
|
|
22
|
-
expect(typeof agents.AgentOrchestrator).toBe("function");
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("exports AgentRouter class", () => {
|
|
26
|
-
expect(agents.AgentRouter).toBeDefined();
|
|
27
|
-
expect(typeof agents.AgentRouter).toBe("function");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
// ─── Memory functions ────────────────────────────────────────────────────
|
|
31
|
-
it("exports memory functions", () => {
|
|
32
|
-
expect(typeof agents.clearMemory).toBe("function");
|
|
33
|
-
expect(typeof agents.getMemory).toBe("function");
|
|
34
|
-
expect(typeof agents.saveMemory).toBe("function");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
// ─── Tenant / context ────────────────────────────────────────────────────
|
|
38
|
-
it("exports tenant utilities", () => {
|
|
39
|
-
expect(typeof agents.checkAgentQuota).toBe("function");
|
|
40
|
-
expect(typeof agents.createAgentContext).toBe("function");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
// ─── Tools ───────────────────────────────────────────────────────────────
|
|
44
|
-
it("exports built-in tools", () => {
|
|
45
|
-
expect(agents.BUILT_IN_TOOLS).toBeDefined();
|
|
46
|
-
expect(agents.databaseQueryTool).toBeDefined();
|
|
47
|
-
expect(agents.knowledgeBaseTool).toBeDefined();
|
|
48
|
-
expect(agents.webSearchTool).toBeDefined();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// ─── Vercel AI SDK helpers (absorbed from @nebutra/ai-sdk) ───────────────
|
|
52
|
-
it("exports configure() and getConfig()", () => {
|
|
53
|
-
expect(typeof agents.configure).toBe("function");
|
|
54
|
-
expect(typeof agents.getConfig).toBe("function");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("exports generateText() and streamText()", () => {
|
|
58
|
-
expect(typeof agents.generateText).toBe("function");
|
|
59
|
-
expect(typeof agents.streamText).toBe("function");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("exports embed() and embedMany()", () => {
|
|
63
|
-
expect(typeof agents.embed).toBe("function");
|
|
64
|
-
expect(typeof agents.embedMany).toBe("function");
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("exports createModel() and createEmbeddingModel()", () => {
|
|
68
|
-
expect(typeof agents.createModel).toBe("function");
|
|
69
|
-
expect(typeof agents.createEmbeddingModel).toBe("function");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("exports models preset map with expected keys", () => {
|
|
73
|
-
expect(agents.models).toBeDefined();
|
|
74
|
-
expect(agents.models.flagship).toBeTypeOf("string");
|
|
75
|
-
expect(agents.models.fast).toBeTypeOf("string");
|
|
76
|
-
expect(agents.models.embedding).toBeTypeOf("string");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("exports resolveModel() and returns presets correctly", () => {
|
|
80
|
-
expect(typeof agents.resolveModel).toBe("function");
|
|
81
|
-
expect(agents.resolveModel("flagship")).toBe(agents.models.flagship);
|
|
82
|
-
expect(agents.resolveModel("custom/passthrough")).toBe("custom/passthrough");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("configure() accepts a provider selection and getConfig() reflects it", () => {
|
|
86
|
-
agents.configure({ provider: "openai", defaultModel: "gpt-4o" });
|
|
87
|
-
const cfg = agents.getConfig();
|
|
88
|
-
expect(cfg.provider).toBe("openai");
|
|
89
|
-
expect(cfg.defaultModel).toBe("gpt-4o");
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("NebutraAIConfigSchema validates input config", () => {
|
|
93
|
-
expect(agents.NebutraAIConfigSchema).toBeDefined();
|
|
94
|
-
const parsed = agents.NebutraAIConfigSchema.parse({});
|
|
95
|
-
expect(parsed.provider).toBe("openrouter"); // default
|
|
96
|
-
expect(parsed.defaultModel).toBeTypeOf("string");
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("AgentOrchestrator can be instantiated with empty agents", () => {
|
|
100
|
-
const orch = new agents.AgentOrchestrator({ agents: [] });
|
|
101
|
-
expect(orch).toBeInstanceOf(agents.AgentOrchestrator);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("BaseAgent can be instantiated with an AgentConfig", () => {
|
|
105
|
-
const agent = new agents.BaseAgent({
|
|
106
|
-
id: "test",
|
|
107
|
-
name: "Test Agent",
|
|
108
|
-
description: "Contract test",
|
|
109
|
-
model: "openai/gpt-4",
|
|
110
|
-
instructions: "You are a test agent.",
|
|
111
|
-
});
|
|
112
|
-
expect(agent.config.id).toBe("test");
|
|
113
|
-
});
|
|
114
|
-
});
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { AgentRuntimeGateway, type AgentRuntimeGatewayProvider } from "../gateway";
|
|
3
|
-
|
|
4
|
-
function provider(
|
|
5
|
-
id: string,
|
|
6
|
-
text: string,
|
|
7
|
-
options: {
|
|
8
|
-
capabilities?: readonly string[];
|
|
9
|
-
fail?: "retryable" | "fatal";
|
|
10
|
-
calls?: { count: number };
|
|
11
|
-
} = {},
|
|
12
|
-
): AgentRuntimeGatewayProvider {
|
|
13
|
-
return {
|
|
14
|
-
id,
|
|
15
|
-
model: `${id}-model`,
|
|
16
|
-
capabilities: new Set(options.capabilities ?? ["reasoning", "tools"]),
|
|
17
|
-
async complete() {
|
|
18
|
-
if (options.calls) options.calls.count += 1;
|
|
19
|
-
if (options.fail === "retryable") {
|
|
20
|
-
throw Object.assign(new Error("rate limited"), { statusCode: 429 });
|
|
21
|
-
}
|
|
22
|
-
if (options.fail === "fatal") {
|
|
23
|
-
throw Object.assign(new Error("unauthorized"), { statusCode: 401 });
|
|
24
|
-
}
|
|
25
|
-
return {
|
|
26
|
-
id: `${id}-call`,
|
|
27
|
-
model: `${id}-model`,
|
|
28
|
-
provider: id,
|
|
29
|
-
text,
|
|
30
|
-
usage: { inputTokens: 2, outputTokens: 3, totalTokens: 5 },
|
|
31
|
-
};
|
|
32
|
-
},
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
describe("AgentRuntimeGateway", () => {
|
|
37
|
-
it("routes by capability and records tenant-aware usage decisions", async () => {
|
|
38
|
-
const gateway = new AgentRuntimeGateway({
|
|
39
|
-
providers: [
|
|
40
|
-
provider("vision-only", "no", { capabilities: ["vision"] }),
|
|
41
|
-
provider("reasoning-tools", "hello", { capabilities: ["reasoning", "tools"] }),
|
|
42
|
-
],
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
const response = await gateway.complete({
|
|
46
|
-
capability: "reasoning+tools",
|
|
47
|
-
tenantId: "org_1",
|
|
48
|
-
userId: "user_1",
|
|
49
|
-
requestId: "req_1",
|
|
50
|
-
messages: [{ role: "user", content: "hi" }],
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
expect(response.text).toBe("hello");
|
|
54
|
-
expect(gateway.usageReport()).toMatchObject({ calls: 1, totalTokens: 5 });
|
|
55
|
-
expect(gateway.debugLog()[0]).toMatchObject({
|
|
56
|
-
requestId: "req_1",
|
|
57
|
-
tenantId: "org_1",
|
|
58
|
-
userId: "user_1",
|
|
59
|
-
decision: {
|
|
60
|
-
provider: "reasoning-tools",
|
|
61
|
-
fallbackIndex: 0,
|
|
62
|
-
},
|
|
63
|
-
ok: true,
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("falls back only for retryable provider errors", async () => {
|
|
68
|
-
const retryable = new AgentRuntimeGateway({
|
|
69
|
-
providers: [provider("primary", "no", { fail: "retryable" }), provider("fallback", "yes")],
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
await expect(
|
|
73
|
-
retryable.complete({
|
|
74
|
-
capability: "reasoning",
|
|
75
|
-
messages: [{ role: "user", content: "hi" }],
|
|
76
|
-
}),
|
|
77
|
-
).resolves.toMatchObject({ provider: "fallback", text: "yes" });
|
|
78
|
-
|
|
79
|
-
const fatal = new AgentRuntimeGateway({
|
|
80
|
-
providers: [provider("primary", "no", { fail: "fatal" }), provider("fallback", "yes")],
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
await expect(
|
|
84
|
-
fatal.complete({
|
|
85
|
-
capability: "reasoning",
|
|
86
|
-
messages: [{ role: "user", content: "hi" }],
|
|
87
|
-
}),
|
|
88
|
-
).rejects.toThrow(/unauthorized/);
|
|
89
|
-
expect(fatal.debugLog()).toHaveLength(1);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("uses a stable prompt cache without invoking the provider twice", async () => {
|
|
93
|
-
const calls = { count: 0 };
|
|
94
|
-
const gateway = new AgentRuntimeGateway({
|
|
95
|
-
providers: [provider("primary", "cached", { calls })],
|
|
96
|
-
});
|
|
97
|
-
const request = {
|
|
98
|
-
capability: "reasoning",
|
|
99
|
-
messages: [{ role: "user" as const, content: "same prompt" }],
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
await gateway.complete(request);
|
|
103
|
-
await gateway.complete(request);
|
|
104
|
-
|
|
105
|
-
expect(calls.count).toBe(1);
|
|
106
|
-
expect(gateway.cacheStats()).toMatchObject({ hits: 1, misses: 1, size: 1 });
|
|
107
|
-
});
|
|
108
|
-
});
|