@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/AGENTS.md +63 -0
- package/LICENSE +676 -0
- package/README.md +78 -0
- package/package.json +71 -0
- package/src/__tests__/cost-observability.test.ts +172 -0
- package/src/__tests__/fallback-wiring.test.ts +313 -0
- package/src/__tests__/public-api.test.ts +114 -0
- package/src/agent.ts +117 -0
- package/src/context.ts +99 -0
- package/src/env.ts +79 -0
- package/src/fallback.ts +358 -0
- package/src/index.ts +86 -0
- package/src/memory.ts +126 -0
- package/src/observability.ts +102 -0
- package/src/orchestrator.ts +147 -0
- package/src/providers/langchain.ts +28 -0
- package/src/providers/vercel-ai.ts +114 -0
- package/src/router.ts +158 -0
- package/src/sdk/config.ts +73 -0
- package/src/sdk/index.ts +214 -0
- package/src/sdk/models.ts +57 -0
- package/src/sdk/provider.ts +80 -0
- package/src/tenant.ts +52 -0
- package/src/tools.ts +65 -0
- package/src/types.ts +114 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +21 -0
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
|
+
});
|