@nebutra/agents 1.0.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/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @nebutra/agents — Nebutra AI Runtime
2
+
3
+ > **Status: Production-ready** — The single AI runtime package for Nebutra.
4
+ > As of v1.0.0 it consolidates the former `@nebutra/ai-sdk` (top-level
5
+ > `generateText` / `streamText` / `embed` helpers) with the multi-agent
6
+ > orchestration framework (`BaseAgent`, `AgentOrchestrator`, memory, tools).
7
+
8
+ ## What lives here
9
+
10
+ ```
11
+ @nebutra/agents
12
+ ├── Top-level Vercel AI SDK helpers (absorbed from @nebutra/ai-sdk)
13
+ │ configure(), generateText(), streamText(), embed(), embedMany()
14
+ │ createModel(), createEmbeddingModel(), models, resolveModel()
15
+ │
16
+ ├── Multi-agent framework
17
+ │ BaseAgent ← Abstract agent with tenant context + usage tracking
18
+ │ AgentOrchestrator ← chat/pipeline/broadcast coordination
19
+ │ AgentRouter ← route messages to the best agent
20
+ │ Memory ← Redis-backed per-tenant conversation persistence
21
+ │ Tools ← BUILT_IN_TOOLS (web_search, db_query, knowledge_base)
22
+ │
23
+ └── Provider adapters
24
+ providers/vercel-ai.ts ← VercelAIAgent (production, streamText + toolLoop)
25
+ providers/langchain.ts ← Optional LangChain stub (throws until you wire it up)
26
+ ```
27
+
28
+ ## Companion package
29
+
30
+ | Package | Role |
31
+ |---------|------|
32
+ | `@nebutra/agents` | Runtime — all AI calls go through here |
33
+ | `@nebutra/ai-providers` | Meta-only — provider registry data + scaffolding templates consumed by `@nebutra/create-sailor` |
34
+
35
+ The former `@nebutra/ai-sdk` was absorbed into this package in v1.0.0.
36
+ The former `@nebutra/langchain` stub was deleted (no callers); the LangChain
37
+ integration hook lives here in `providers/langchain.ts` as an extension point.
38
+
39
+ ## Quick start — single-shot generation
40
+
41
+ ```ts
42
+ import { configure, streamText } from "@nebutra/agents";
43
+
44
+ configure({ provider: "openrouter", defaultModel: "anthropic/claude-sonnet-4" });
45
+
46
+ const result = await streamText(
47
+ [{ role: "user", content: "Explain monorepos" }],
48
+ { model: "fast" },
49
+ );
50
+ return result.toUIMessageStreamResponse();
51
+ ```
52
+
53
+ ## Quick start — multi-agent orchestration
54
+
55
+ ```ts
56
+ import { AgentOrchestrator, createAgentContext } from "@nebutra/agents";
57
+ import { VercelAIAgent } from "@nebutra/agents/providers/vercel-ai";
58
+
59
+ const orchestrator = new AgentOrchestrator({
60
+ agents: [
61
+ { id: "assistant", name: "Assistant", description: "Helpful", model: "openai/gpt-4", instructions: "..." },
62
+ ],
63
+ });
64
+
65
+ // Swap the BaseAgent for a real VercelAIAgent at runtime:
66
+ orchestrator.registerAgent(
67
+ new VercelAIAgent({ id: "assistant", name: "Assistant", description: "Helpful", model: "openai/gpt-4", instructions: "..." }),
68
+ );
69
+
70
+ const ctx = createAgentContext("org_123", "user_456");
71
+ const response = await orchestrator.chat("Hello", ctx);
72
+ // → response.usage tracks tokens for billing
73
+ ```
74
+
75
+ ## Multi-tenant by design
76
+
77
+ Every agent operation requires a `tenantId`. Usage events are emitted for
78
+ billing and metering integration (see `@nebutra/billing/credits`).
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@nebutra/agents",
3
+ "version": "1.0.0",
4
+ "description": "Nebutra AI runtime: multi-agent orchestration + Vercel AI SDK helpers (absorbed @nebutra/ai-sdk in 1.0.0)",
5
+ "private": false,
6
+ "license": "AGPL-3.0",
7
+ "type": "module",
8
+ "nebutra": {
9
+ "featureId": "agents",
10
+ "category": "ai",
11
+ "summary": "Nebutra AI runtime: multi-agent orchestration + Vercel AI SDK helpers"
12
+ },
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./tools": "./src/tools.ts",
18
+ "./providers/vercel-ai": "./src/providers/vercel-ai.ts",
19
+ "./providers/langchain": "./src/providers/langchain.ts",
20
+ "./sdk": "./src/sdk/index.ts",
21
+ "./sdk/config": "./src/sdk/config.ts",
22
+ "./sdk/models": "./src/sdk/models.ts",
23
+ "./sdk/provider": "./src/sdk/provider.ts",
24
+ "./env": "./src/env.ts",
25
+ "./observability": "./src/observability.ts",
26
+ "./fallback": "./src/fallback.ts"
27
+ },
28
+ "dependencies": {
29
+ "@ai-sdk/anthropic": "^3.0.76",
30
+ "@ai-sdk/openai": "^3.0.41",
31
+ "@openrouter/ai-sdk-provider": "^2.3.3",
32
+ "langfuse": "^3.38.20",
33
+ "langfuse-vercel": "^3.38.20",
34
+ "zod": "^4.3.6",
35
+ "@nebutra/billing": "0.1.0",
36
+ "@nebutra/cache": "0.0.1",
37
+ "@nebutra/logger": "0.1.0"
38
+ },
39
+ "peerDependencies": {
40
+ "ai": "^6.0.0"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "ai": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.19.15",
49
+ "tsup": "^8.5.1",
50
+ "typescript": "^5.9.3",
51
+ "vitest": "^4.0.18"
52
+ },
53
+ "homepage": "https://github.com/Nebutra/Nebutra-Sailor/tree/main/packages/ai/agents#readme",
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/Nebutra/Nebutra-Sailor.git",
57
+ "directory": "packages/ai/agents"
58
+ },
59
+ "bugs": {
60
+ "url": "https://github.com/Nebutra/Nebutra-Sailor/issues"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "scripts": {
66
+ "build": "tsup",
67
+ "dev": "tsup --watch",
68
+ "test": "vitest run",
69
+ "typecheck": "tsc --noEmit"
70
+ }
71
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Cost & observability primitives:
3
+ * - Anthropic prompt cache control wiring
4
+ * - Multi-provider fallback chain on retryable errors
5
+ * - Langfuse no-op when env missing
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+
10
+ import { _resetAgentsEnvCache } from "../env";
11
+ import {
12
+ buildSystemWithCache,
13
+ isRetryableError,
14
+ runWithFallback,
15
+ withAnthropicCacheControl,
16
+ } from "../fallback";
17
+ import { _resetLangfuseCache, buildTelemetryConfig, initLangfuse } from "../observability";
18
+
19
+ describe("withAnthropicCacheControl()", () => {
20
+ it("returns ephemeral cache control under the anthropic provider key", () => {
21
+ const opts = withAnthropicCacheControl();
22
+ expect(opts.anthropic.cacheControl.type).toBe("ephemeral");
23
+ });
24
+
25
+ it("wraps a system prompt with cache-control providerOptions", () => {
26
+ const wrapped = buildSystemWithCache("You are a helpful assistant.");
27
+ expect(wrapped.role).toBe("system");
28
+ expect(wrapped.content).toBe("You are a helpful assistant.");
29
+ expect(wrapped.providerOptions.anthropic.cacheControl.type).toBe("ephemeral");
30
+ });
31
+ });
32
+
33
+ describe("isRetryableError()", () => {
34
+ it.each([429, 500, 502, 503, 504, 408])("marks status %i as retryable", (statusCode) => {
35
+ expect(isRetryableError({ statusCode })).toBe(true);
36
+ });
37
+
38
+ it("marks ECONNRESET / ETIMEDOUT as retryable", () => {
39
+ expect(isRetryableError({ code: "ECONNRESET" })).toBe(true);
40
+ expect(isRetryableError({ code: "ETIMEDOUT" })).toBe(true);
41
+ });
42
+
43
+ it("marks 4xx auth/validation errors as non-retryable", () => {
44
+ expect(isRetryableError({ statusCode: 401 })).toBe(false);
45
+ expect(isRetryableError({ statusCode: 403 })).toBe(false);
46
+ expect(isRetryableError({ statusCode: 400 })).toBe(false);
47
+ });
48
+
49
+ it("returns false for null / undefined / non-objects", () => {
50
+ expect(isRetryableError(null)).toBe(false);
51
+ expect(isRetryableError(undefined)).toBe(false);
52
+ expect(isRetryableError("oops")).toBe(false);
53
+ });
54
+
55
+ it("respects an explicit isRetryable=true flag (AI SDK APICallError)", () => {
56
+ expect(isRetryableError({ isRetryable: true })).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe("runWithFallback()", () => {
61
+ beforeEach(() => {
62
+ _resetAgentsEnvCache();
63
+ // Provide stub keys so buildModel() doesn't throw before invoke() runs.
64
+ vi.stubEnv("OPENROUTER_API_KEY", "test-or");
65
+ vi.stubEnv("ANTHROPIC_API_KEY", "test-an");
66
+ vi.stubEnv("OPENAI_API_KEY", "test-oa");
67
+ });
68
+
69
+ afterEach(() => {
70
+ vi.unstubAllEnvs();
71
+ });
72
+
73
+ it("falls through retryable errors and returns the next provider's result", async () => {
74
+ let calls = 0;
75
+ const result = await runWithFallback(
76
+ async () => {
77
+ calls += 1;
78
+ if (calls === 1) {
79
+ // simulate primary provider 503
80
+ const err = Object.assign(new Error("Service Unavailable"), {
81
+ statusCode: 503,
82
+ });
83
+ throw err;
84
+ }
85
+ return "ok-from-fallback" as const;
86
+ },
87
+ {
88
+ chain: ["openrouter", "anthropic"],
89
+ // Stub buildModel by injecting fake creds via env
90
+ model: "flagship",
91
+ },
92
+ );
93
+
94
+ expect(result.result).toBe("ok-from-fallback");
95
+ expect(result.attempts).toBe(2);
96
+ expect(result.provider).toBe("anthropic");
97
+ });
98
+
99
+ it("rethrows non-retryable errors immediately (no fallback)", async () => {
100
+ let calls = 0;
101
+ await expect(
102
+ runWithFallback(
103
+ async () => {
104
+ calls += 1;
105
+ throw Object.assign(new Error("Unauthorized"), { statusCode: 401 });
106
+ },
107
+ { chain: ["openrouter", "anthropic"] },
108
+ ),
109
+ ).rejects.toThrow(/Unauthorized/);
110
+ expect(calls).toBe(1);
111
+ });
112
+
113
+ it("throws an aggregate error when the entire chain fails with retryables", async () => {
114
+ await expect(
115
+ runWithFallback(
116
+ async () => {
117
+ throw Object.assign(new Error("503"), { statusCode: 503 });
118
+ },
119
+ { chain: ["openrouter", "anthropic", "openai"] },
120
+ ),
121
+ ).rejects.toThrow(/All LLM providers in fallback chain/);
122
+ });
123
+ });
124
+
125
+ describe("Langfuse telemetry — no-op when env missing", () => {
126
+ beforeEach(() => {
127
+ _resetLangfuseCache();
128
+ _resetAgentsEnvCache();
129
+ vi.unstubAllEnvs();
130
+ vi.stubEnv("LANGFUSE_PUBLIC_KEY", "");
131
+ vi.stubEnv("LANGFUSE_SECRET_KEY", "");
132
+ });
133
+
134
+ afterEach(() => {
135
+ vi.unstubAllEnvs();
136
+ _resetLangfuseCache();
137
+ _resetAgentsEnvCache();
138
+ });
139
+
140
+ it("initLangfuse() returns null when keys missing", async () => {
141
+ const client = await initLangfuse();
142
+ expect(client).toBeNull();
143
+ });
144
+
145
+ it("buildTelemetryConfig() returns isEnabled=false when keys missing", () => {
146
+ const cfg = buildTelemetryConfig({ functionId: "test-fn" });
147
+ expect(cfg.isEnabled).toBe(false);
148
+ expect(cfg.functionId).toBeUndefined();
149
+ });
150
+
151
+ it("buildTelemetryConfig() includes tenant/session metadata when configured", () => {
152
+ vi.stubEnv("LANGFUSE_PUBLIC_KEY", "pk-test");
153
+ vi.stubEnv("LANGFUSE_SECRET_KEY", "sk-test");
154
+ _resetAgentsEnvCache();
155
+
156
+ const cfg = buildTelemetryConfig({
157
+ functionId: "agent.support-bot",
158
+ metadata: {
159
+ tenantId: "org_123",
160
+ userId: "user_456",
161
+ sessionId: "conv_789",
162
+ agentId: "support-bot",
163
+ },
164
+ });
165
+
166
+ expect(cfg.isEnabled).toBe(true);
167
+ expect(cfg.functionId).toBe("agent.support-bot");
168
+ expect(cfg.metadata?.tenantId).toBe("org_123");
169
+ expect(cfg.metadata?.langfuseUserId).toBe("org_123");
170
+ expect(cfg.metadata?.langfuseSessionId).toBe("conv_789");
171
+ });
172
+ });
@@ -0,0 +1,313 @@
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
+ });