@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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentOrchestrator — multi-agent coordination engine.
|
|
3
|
+
*
|
|
4
|
+
* Supports three execution modes:
|
|
5
|
+
* - chat(): route a single message to the best agent
|
|
6
|
+
* - pipeline(): chain agents sequentially (output → next input)
|
|
7
|
+
* - broadcast(): fan-out to all agents and collect results
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { logger } from "@nebutra/logger";
|
|
11
|
+
import { BaseAgent } from "./agent";
|
|
12
|
+
import { AgentRouter } from "./router";
|
|
13
|
+
import { checkAgentQuota } from "./tenant";
|
|
14
|
+
import type {
|
|
15
|
+
AgentContext,
|
|
16
|
+
AgentMessage,
|
|
17
|
+
AgentResponse,
|
|
18
|
+
OrchestratorConfig,
|
|
19
|
+
PipelineStep,
|
|
20
|
+
} from "./types";
|
|
21
|
+
|
|
22
|
+
export class AgentOrchestrator {
|
|
23
|
+
private readonly agents: Map<string, BaseAgent>;
|
|
24
|
+
private readonly router: AgentRouter;
|
|
25
|
+
private readonly defaultAgentId: string | undefined;
|
|
26
|
+
|
|
27
|
+
constructor(config: OrchestratorConfig) {
|
|
28
|
+
this.agents = new Map();
|
|
29
|
+
this.defaultAgentId = config.defaultAgentId;
|
|
30
|
+
|
|
31
|
+
// Register agents — callers provide AgentConfig[], we wrap in BaseAgent
|
|
32
|
+
// In practice, callers will register concrete subclasses (VercelAIAgent, etc.)
|
|
33
|
+
for (const agentConfig of config.agents) {
|
|
34
|
+
this.agents.set(agentConfig.id, new BaseAgent(agentConfig));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Set up router
|
|
38
|
+
this.router = new AgentRouter(config.router ?? { strategy: "keyword" });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register a pre-built agent instance (e.g. VercelAIAgent).
|
|
43
|
+
* Overwrites any agent with the same ID.
|
|
44
|
+
*/
|
|
45
|
+
registerAgent(agent: BaseAgent): void {
|
|
46
|
+
this.agents.set(agent.config.id, agent);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get a registered agent by ID.
|
|
51
|
+
*/
|
|
52
|
+
getAgent(agentId: string): BaseAgent | undefined {
|
|
53
|
+
return this.agents.get(agentId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Route a message to the best agent and execute.
|
|
58
|
+
*/
|
|
59
|
+
async chat(message: string, context: AgentContext): Promise<AgentResponse> {
|
|
60
|
+
await this.assertQuota(context.tenantId);
|
|
61
|
+
|
|
62
|
+
const agentConfigs = [...this.agents.values()].map((a) => a.config);
|
|
63
|
+
const agentId = await this.router.route(message, agentConfigs, context, this.defaultAgentId);
|
|
64
|
+
|
|
65
|
+
const agent = this.agents.get(agentId);
|
|
66
|
+
if (!agent) {
|
|
67
|
+
throw new Error(`Agent "${agentId}" not found in orchestrator`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const messages: AgentMessage[] = [{ role: "user", content: message, timestamp: new Date() }];
|
|
71
|
+
|
|
72
|
+
return agent.run(messages, context);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Execute a multi-agent pipeline where each step's output feeds the next.
|
|
77
|
+
* @experimental — API may change. Use `chat()` for production workloads.
|
|
78
|
+
*/
|
|
79
|
+
async pipeline(
|
|
80
|
+
steps: readonly PipelineStep[],
|
|
81
|
+
input: string,
|
|
82
|
+
context: AgentContext,
|
|
83
|
+
): Promise<AgentResponse> {
|
|
84
|
+
await this.assertQuota(context.tenantId);
|
|
85
|
+
|
|
86
|
+
let currentInput = input;
|
|
87
|
+
let lastResponse: AgentResponse | undefined;
|
|
88
|
+
|
|
89
|
+
for (const step of steps) {
|
|
90
|
+
const agent = this.agents.get(step.agentId);
|
|
91
|
+
if (!agent) {
|
|
92
|
+
throw new Error(`Pipeline step references unknown agent "${step.agentId}"`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const transformedInput = step.transformInput
|
|
96
|
+
? step.transformInput(currentInput)
|
|
97
|
+
: currentInput;
|
|
98
|
+
|
|
99
|
+
const messages: AgentMessage[] = [
|
|
100
|
+
{ role: "user", content: transformedInput, timestamp: new Date() },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
lastResponse = await agent.run(messages, context);
|
|
104
|
+
|
|
105
|
+
// Extract the last assistant message as input for the next step
|
|
106
|
+
const assistantMessages = lastResponse.messages.filter((m) => m.role === "assistant");
|
|
107
|
+
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
|
108
|
+
currentInput = lastAssistant?.content ?? "";
|
|
109
|
+
|
|
110
|
+
logger.info("Pipeline step completed", {
|
|
111
|
+
agentId: step.agentId,
|
|
112
|
+
tenantId: context.tenantId,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!lastResponse) {
|
|
117
|
+
throw new Error("Pipeline produced no response (empty steps?)");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return lastResponse;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Broadcast a message to ALL registered agents in parallel.
|
|
125
|
+
* Returns an array of responses (one per agent).
|
|
126
|
+
* @experimental — API may change. Use `chat()` for production workloads.
|
|
127
|
+
*/
|
|
128
|
+
async broadcast(message: string, context: AgentContext): Promise<readonly AgentResponse[]> {
|
|
129
|
+
await this.assertQuota(context.tenantId);
|
|
130
|
+
|
|
131
|
+
const messages: AgentMessage[] = [{ role: "user", content: message, timestamp: new Date() }];
|
|
132
|
+
|
|
133
|
+
const promises = [...this.agents.values()].map((agent) => agent.run(messages, context));
|
|
134
|
+
|
|
135
|
+
return Promise.all(promises);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check tenant quota before execution.
|
|
140
|
+
*/
|
|
141
|
+
private async assertQuota(tenantId: string): Promise<void> {
|
|
142
|
+
const { allowed } = await checkAgentQuota(tenantId);
|
|
143
|
+
if (!allowed) {
|
|
144
|
+
throw new Error(`Tenant "${tenantId}" has exceeded agent execution quota`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LangChain.js agent adapter — stub.
|
|
3
|
+
*
|
|
4
|
+
* This module intentionally throws at construction time to guide
|
|
5
|
+
* developers through the required dependency installation.
|
|
6
|
+
*
|
|
7
|
+
* Once the packages are installed, implement the `execute()` method
|
|
8
|
+
* using LangChain's AgentExecutor or the newer LangGraph approach.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { BaseAgent } from "../agent";
|
|
12
|
+
import type { AgentConfig } from "../types";
|
|
13
|
+
|
|
14
|
+
export class LangChainAgent extends BaseAgent {
|
|
15
|
+
constructor(config: AgentConfig) {
|
|
16
|
+
super(config);
|
|
17
|
+
throw new Error(
|
|
18
|
+
[
|
|
19
|
+
"LangChain agent requires additional packages. Install:",
|
|
20
|
+
"",
|
|
21
|
+
" pnpm add langchain @langchain/core @langchain/openai",
|
|
22
|
+
"",
|
|
23
|
+
"Then implement the execute() method in this file using",
|
|
24
|
+
"LangChain's AgentExecutor or LangGraph.",
|
|
25
|
+
].join("\n"),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel AI SDK agent adapter.
|
|
3
|
+
*
|
|
4
|
+
* Uses `streamText` from the `ai` package with tool-loop support.
|
|
5
|
+
* The `ai` peer dependency is dynamically imported so the package
|
|
6
|
+
* doesn't fail at require-time when the SDK is absent.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { BaseAgent } from "../agent";
|
|
10
|
+
import { runWithFallback, withAnthropicCacheControl } from "../fallback";
|
|
11
|
+
import { buildTelemetryConfig } from "../observability";
|
|
12
|
+
import type { AgentContext, AgentMessage, AgentResponse } from "../types";
|
|
13
|
+
|
|
14
|
+
export class VercelAIAgent extends BaseAgent {
|
|
15
|
+
protected override async execute(
|
|
16
|
+
messages: readonly AgentMessage[],
|
|
17
|
+
context: AgentContext,
|
|
18
|
+
): Promise<AgentResponse> {
|
|
19
|
+
// Dynamic import — avoids hard dependency on `ai`
|
|
20
|
+
const { streamText, stepCountIs, dynamicTool } = await import("ai");
|
|
21
|
+
|
|
22
|
+
type StreamTextParams = Parameters<typeof streamText>[0];
|
|
23
|
+
|
|
24
|
+
// Build AI SDK tool definitions from our AgentTool interface.
|
|
25
|
+
// We use dynamicTool() because our AgentTool uses a loose JSON Schema
|
|
26
|
+
// record type rather than a typed Zod schema.
|
|
27
|
+
const toolSet: StreamTextParams["tools"] = this.config.tools
|
|
28
|
+
? Object.fromEntries(
|
|
29
|
+
this.config.tools.map((t) => [
|
|
30
|
+
t.name,
|
|
31
|
+
dynamicTool({
|
|
32
|
+
description: t.description,
|
|
33
|
+
inputSchema: t.inputSchema as Parameters<typeof dynamicTool>[0]["inputSchema"],
|
|
34
|
+
execute: async (args) => t.execute(args, context),
|
|
35
|
+
}),
|
|
36
|
+
]),
|
|
37
|
+
)
|
|
38
|
+
: undefined;
|
|
39
|
+
|
|
40
|
+
// Build streamText options, conditionally including tools to satisfy
|
|
41
|
+
// exactOptionalPropertyTypes (tools must not be `undefined`).
|
|
42
|
+
//
|
|
43
|
+
// Cost optimization: stable content (system prompt + tool defs) is placed
|
|
44
|
+
// FIRST and dynamic content (user messages) LAST. This ordering is required
|
|
45
|
+
// for both Anthropic explicit prompt caching (90% discount via `cacheControl`)
|
|
46
|
+
// and OpenAI automatic caching (≥1024 token stable prefix). Reordering or
|
|
47
|
+
// mutating the system prompt invalidates the cache on every call.
|
|
48
|
+
const telemetry = buildTelemetryConfig({
|
|
49
|
+
functionId: `agent.${this.config.id}`,
|
|
50
|
+
metadata: {
|
|
51
|
+
tenantId: context.tenantId,
|
|
52
|
+
userId: context.userId,
|
|
53
|
+
sessionId: context.conversationId,
|
|
54
|
+
agentId: this.config.id,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const baseOptionsWithoutModel = {
|
|
59
|
+
system: this.config.instructions,
|
|
60
|
+
messages: messages.map((m) => ({
|
|
61
|
+
role: m.role as "user" | "assistant" | "system",
|
|
62
|
+
content: m.content,
|
|
63
|
+
})),
|
|
64
|
+
stopWhen: stepCountIs(this.config.maxSteps ?? 20),
|
|
65
|
+
// Anthropic prompt cache control on the system message — 90% cost
|
|
66
|
+
// reduction on cached prefix tokens. No-op for non-Anthropic providers.
|
|
67
|
+
providerOptions: withAnthropicCacheControl(),
|
|
68
|
+
experimental_telemetry: telemetry,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Run through the multi-provider fallback chain. The chain is filtered
|
|
72
|
+
// to providers with API keys present (so single-provider deploys are
|
|
73
|
+
// backward-compatible — only the configured provider is tried).
|
|
74
|
+
const { result } = await runWithFallback(
|
|
75
|
+
async (model) => {
|
|
76
|
+
const streamOptions = {
|
|
77
|
+
...baseOptionsWithoutModel,
|
|
78
|
+
model,
|
|
79
|
+
...(toolSet !== undefined ? { tools: toolSet } : {}),
|
|
80
|
+
} as StreamTextParams;
|
|
81
|
+
const r = streamText(streamOptions);
|
|
82
|
+
// Materialize the result so retryable errors surface inside the
|
|
83
|
+
// try/catch in runWithFallback() rather than escaping as unhandled
|
|
84
|
+
// rejections from the lazy stream.
|
|
85
|
+
const text = await r.text;
|
|
86
|
+
const usage = await r.usage;
|
|
87
|
+
return { text, usage };
|
|
88
|
+
},
|
|
89
|
+
{ model: this.config.model },
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const { text, usage } = result;
|
|
93
|
+
|
|
94
|
+
// AI SDK v6 uses inputTokens/outputTokens on LanguageModelUsage
|
|
95
|
+
const inputTokens = usage?.inputTokens ?? 0;
|
|
96
|
+
const outputTokens = usage?.outputTokens ?? 0;
|
|
97
|
+
|
|
98
|
+
const responseMessages: AgentMessage[] = [
|
|
99
|
+
...messages,
|
|
100
|
+
{ role: "assistant" as const, content: text, timestamp: new Date() },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
messages: responseMessages,
|
|
105
|
+
usage: {
|
|
106
|
+
promptTokens: inputTokens,
|
|
107
|
+
completionTokens: outputTokens,
|
|
108
|
+
totalTokens: inputTokens + outputTokens,
|
|
109
|
+
},
|
|
110
|
+
finishReason: "stop",
|
|
111
|
+
agentId: this.config.id,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentRouter — routes incoming messages to the appropriate agent.
|
|
3
|
+
*
|
|
4
|
+
* Strategies:
|
|
5
|
+
* - keyword: fast, pattern-based matching against agent descriptions
|
|
6
|
+
* - llm: uses a small model to classify intent (requires AI SDK)
|
|
7
|
+
* - custom: user-supplied routing function
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { logger } from "@nebutra/logger";
|
|
11
|
+
import type { AgentConfig, AgentContext, RouterConfig } from "./types";
|
|
12
|
+
|
|
13
|
+
export class AgentRouter {
|
|
14
|
+
private readonly config: RouterConfig;
|
|
15
|
+
|
|
16
|
+
constructor(config: RouterConfig) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Route a message to the best-matching agent.
|
|
22
|
+
* Returns the agent ID.
|
|
23
|
+
*/
|
|
24
|
+
async route(
|
|
25
|
+
message: string,
|
|
26
|
+
agents: readonly AgentConfig[],
|
|
27
|
+
context: AgentContext,
|
|
28
|
+
defaultAgentId?: string,
|
|
29
|
+
): Promise<string> {
|
|
30
|
+
if (agents.length === 0) {
|
|
31
|
+
throw new Error("No agents configured in the orchestrator");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fallback = defaultAgentId ?? agents[0]?.id;
|
|
35
|
+
if (!fallback) {
|
|
36
|
+
throw new Error("No agents configured in the orchestrator");
|
|
37
|
+
}
|
|
38
|
+
switch (this.config.strategy) {
|
|
39
|
+
case "keyword":
|
|
40
|
+
return this.routeByKeyword(message, agents, defaultAgentId) ?? fallback;
|
|
41
|
+
case "llm":
|
|
42
|
+
return (await this.routeByLLM(message, agents, defaultAgentId)) ?? fallback;
|
|
43
|
+
case "custom":
|
|
44
|
+
return this.routeByCustom(message, context, defaultAgentId);
|
|
45
|
+
default: {
|
|
46
|
+
const _exhaustive: never = this.config.strategy;
|
|
47
|
+
throw new Error(`Unknown routing strategy: ${String(_exhaustive)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Keyword-based routing: score each agent by how many words from
|
|
54
|
+
* its name + description appear in the user message.
|
|
55
|
+
*/
|
|
56
|
+
private routeByKeyword(
|
|
57
|
+
message: string,
|
|
58
|
+
agents: readonly AgentConfig[],
|
|
59
|
+
defaultAgentId?: string,
|
|
60
|
+
): string | undefined {
|
|
61
|
+
const messageLower = message.toLowerCase();
|
|
62
|
+
let bestId = defaultAgentId ?? agents[0]?.id;
|
|
63
|
+
let bestScore = 0;
|
|
64
|
+
|
|
65
|
+
for (const agent of agents) {
|
|
66
|
+
const keywords = `${agent.name} ${agent.description}`.toLowerCase().split(/\s+/);
|
|
67
|
+
|
|
68
|
+
let score = 0;
|
|
69
|
+
for (const keyword of keywords) {
|
|
70
|
+
if (keyword.length > 2 && messageLower.includes(keyword)) {
|
|
71
|
+
score += 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (score > bestScore) {
|
|
76
|
+
bestScore = score;
|
|
77
|
+
bestId = agent.id;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
logger.info("Router: keyword match", {
|
|
82
|
+
selectedAgentId: bestId,
|
|
83
|
+
score: bestScore,
|
|
84
|
+
});
|
|
85
|
+
return bestId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* LLM-based routing: uses a small model to classify the user's intent
|
|
90
|
+
* and select the best agent. Falls back to keyword if AI SDK unavailable.
|
|
91
|
+
*/
|
|
92
|
+
private async routeByLLM(
|
|
93
|
+
message: string,
|
|
94
|
+
agents: readonly AgentConfig[],
|
|
95
|
+
defaultAgentId?: string,
|
|
96
|
+
): Promise<string | undefined> {
|
|
97
|
+
try {
|
|
98
|
+
const { generateText } = await import("ai");
|
|
99
|
+
|
|
100
|
+
const agentList = agents.map((a) => `- ${a.id}: ${a.description}`).join("\n");
|
|
101
|
+
|
|
102
|
+
const result = await generateText({
|
|
103
|
+
model: agents[0]?.model as unknown as Parameters<typeof generateText>[0]["model"],
|
|
104
|
+
system: [
|
|
105
|
+
"You are a routing classifier. Given a user message and a list of agents,",
|
|
106
|
+
"respond with ONLY the agent ID that best matches the user's intent.",
|
|
107
|
+
"If no agent is a good match, respond with the default agent ID.",
|
|
108
|
+
"",
|
|
109
|
+
`Available agents:\n${agentList}`,
|
|
110
|
+
"",
|
|
111
|
+
defaultAgentId ? `Default agent: ${defaultAgentId}` : "",
|
|
112
|
+
].join("\n"),
|
|
113
|
+
messages: [{ role: "user" as const, content: message }],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const selectedId = result.text.trim();
|
|
117
|
+
const validAgent = agents.find((a) => a.id === selectedId);
|
|
118
|
+
|
|
119
|
+
if (validAgent) {
|
|
120
|
+
logger.info("Router: LLM classification", {
|
|
121
|
+
selectedAgentId: validAgent.id,
|
|
122
|
+
});
|
|
123
|
+
return validAgent.id;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// LLM returned an invalid ID — fall back
|
|
127
|
+
return defaultAgentId ?? agents[0]?.id;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
logger.warn("Router: LLM routing failed, falling back to keyword", {
|
|
130
|
+
error,
|
|
131
|
+
});
|
|
132
|
+
return this.routeByKeyword(message, agents, defaultAgentId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Custom routing via user-supplied function.
|
|
138
|
+
*/
|
|
139
|
+
private async routeByCustom(
|
|
140
|
+
message: string,
|
|
141
|
+
context: AgentContext,
|
|
142
|
+
defaultAgentId?: string,
|
|
143
|
+
): Promise<string> {
|
|
144
|
+
if (!this.config.customRouter) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
'RouterConfig strategy is "custom" but no customRouter function was provided',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
return await this.config.customRouter(message, context);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
logger.warn("Router: custom router failed", { error });
|
|
154
|
+
if (defaultAgentId) return defaultAgentId;
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime configuration for the Vercel AI SDK-backed helpers
|
|
3
|
+
* (`generateText`, `streamText`, `embed`, `embedMany`).
|
|
4
|
+
*
|
|
5
|
+
* Previously lived in `@nebutra/ai-sdk/config`. Absorbed into
|
|
6
|
+
* `@nebutra/agents` during the AI package consolidation so there is
|
|
7
|
+
* a single AI runtime package.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Supported AI provider backends for the top-level helpers.
|
|
14
|
+
*
|
|
15
|
+
* - openrouter: 300+ models, automatic failover, pay-as-you-go (default)
|
|
16
|
+
* - openai: Direct OpenAI API access
|
|
17
|
+
* - siliconflow: SiliconFlow cloud — Qwen, DeepSeek, etc. (OpenAI-compatible, China-optimized)
|
|
18
|
+
* - gateway: Vercel AI Gateway with OIDC auth (for Vercel-deployed apps)
|
|
19
|
+
*/
|
|
20
|
+
export const ProviderType = z.enum(["openrouter", "openai", "siliconflow", "gateway"]);
|
|
21
|
+
export type ProviderType = z.infer<typeof ProviderType>;
|
|
22
|
+
|
|
23
|
+
export const NebutraAIConfigSchema = z.object({
|
|
24
|
+
/** Which provider backend to use. Defaults to "openrouter". */
|
|
25
|
+
provider: ProviderType.default("openrouter"),
|
|
26
|
+
|
|
27
|
+
/** API key override. Falls back to env vars per provider. */
|
|
28
|
+
apiKey: z.string().optional(),
|
|
29
|
+
|
|
30
|
+
/** Default model identifier. Provider-specific format. */
|
|
31
|
+
defaultModel: z.string().default("anthropic/claude-sonnet-4"),
|
|
32
|
+
|
|
33
|
+
/** Default temperature for generations. */
|
|
34
|
+
temperature: z.number().min(0).max(2).default(0.7),
|
|
35
|
+
|
|
36
|
+
/** Default max tokens for output. */
|
|
37
|
+
maxTokens: z.number().int().positive().optional(),
|
|
38
|
+
|
|
39
|
+
/** Extra headers merged into every request (e.g. HTTP-Referer for OpenRouter). */
|
|
40
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
41
|
+
|
|
42
|
+
/** Extra body fields merged into every request. */
|
|
43
|
+
extraBody: z.record(z.string(), z.unknown()).optional(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export type NebutraAIConfig = z.input<typeof NebutraAIConfigSchema>;
|
|
47
|
+
export type ResolvedNebutraAIConfig = z.output<typeof NebutraAIConfigSchema>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolves the API key from explicit config or environment variables.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveApiKey(config: ResolvedNebutraAIConfig): string {
|
|
53
|
+
if (config.apiKey) return config.apiKey;
|
|
54
|
+
|
|
55
|
+
const envMap: Record<ProviderType, string> = {
|
|
56
|
+
openrouter: "OPENROUTER_API_KEY",
|
|
57
|
+
openai: "OPENAI_API_KEY",
|
|
58
|
+
siliconflow: "SILICONFLOW_API_KEY",
|
|
59
|
+
gateway: "VERCEL_OIDC_TOKEN",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const envVar = envMap[config.provider];
|
|
63
|
+
|
|
64
|
+
const value = globalThis.process?.env?.[envVar];
|
|
65
|
+
|
|
66
|
+
if (!value) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[@nebutra/agents] Missing API key. Set "${envVar}" in environment or pass "apiKey" in config.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return value;
|
|
73
|
+
}
|