@funkai/agents 0.1.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/.generated/req.txt +1 -0
- package/.turbo/turbo-build.log +21 -0
- package/.turbo/turbo-test$colon$coverage.log +109 -0
- package/.turbo/turbo-test.log +141 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +16 -0
- package/ISSUES.md +540 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/banner.svg +97 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/core/agents/base/agent.ts.html +1705 -0
- package/coverage/lcov-report/core/agents/base/index.html +146 -0
- package/coverage/lcov-report/core/agents/base/output.ts.html +256 -0
- package/coverage/lcov-report/core/agents/base/utils.ts.html +694 -0
- package/coverage/lcov-report/core/agents/flow/engine.ts.html +928 -0
- package/coverage/lcov-report/core/agents/flow/flow-agent.ts.html +1462 -0
- package/coverage/lcov-report/core/agents/flow/index.html +146 -0
- package/coverage/lcov-report/core/agents/flow/messages.ts.html +508 -0
- package/coverage/lcov-report/core/agents/flow/steps/factory.ts.html +1975 -0
- package/coverage/lcov-report/core/agents/flow/steps/index.html +116 -0
- package/coverage/lcov-report/core/index.html +131 -0
- package/coverage/lcov-report/core/logger.ts.html +541 -0
- package/coverage/lcov-report/core/models/providers/index.html +116 -0
- package/coverage/lcov-report/core/models/providers/openai.ts.html +337 -0
- package/coverage/lcov-report/core/provider/index.html +131 -0
- package/coverage/lcov-report/core/provider/provider.ts.html +346 -0
- package/coverage/lcov-report/core/provider/usage.ts.html +376 -0
- package/coverage/lcov-report/core/tool.ts.html +577 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +221 -0
- package/coverage/lcov-report/lib/hooks.ts.html +262 -0
- package/coverage/lcov-report/lib/index.html +161 -0
- package/coverage/lcov-report/lib/middleware.ts.html +274 -0
- package/coverage/lcov-report/lib/runnable.ts.html +151 -0
- package/coverage/lcov-report/lib/trace.ts.html +520 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/utils/attempt.ts.html +199 -0
- package/coverage/lcov-report/utils/error.ts.html +421 -0
- package/coverage/lcov-report/utils/index.html +176 -0
- package/coverage/lcov-report/utils/resolve.ts.html +208 -0
- package/coverage/lcov-report/utils/result.ts.html +538 -0
- package/coverage/lcov-report/utils/zod.ts.html +178 -0
- package/coverage/lcov.info +1566 -0
- package/dist/index.d.mts +2883 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2312 -0
- package/dist/index.mjs.map +1 -0
- package/docs/core/agent.md +231 -0
- package/docs/core/hooks.md +95 -0
- package/docs/core/overview.md +87 -0
- package/docs/core/step.md +279 -0
- package/docs/core/tools.md +98 -0
- package/docs/core/workflow.md +235 -0
- package/docs/guides/create-agent.md +224 -0
- package/docs/guides/create-tool.md +137 -0
- package/docs/guides/create-workflow.md +374 -0
- package/docs/overview.md +244 -0
- package/docs/provider/models.md +55 -0
- package/docs/provider/overview.md +106 -0
- package/docs/provider/usage.md +100 -0
- package/docs/research/experimental-context.md +167 -0
- package/docs/research/gap-analysis.md +86 -0
- package/docs/research/prepare-step-and-active-tools.md +138 -0
- package/docs/research/sub-agent-model.md +249 -0
- package/docs/troubleshooting.md +60 -0
- package/logo.svg +17 -0
- package/models.config.json +18 -0
- package/package.json +60 -0
- package/scripts/generate-models.ts +324 -0
- package/src/core/agents/base/agent.test.ts +1522 -0
- package/src/core/agents/base/agent.ts +547 -0
- package/src/core/agents/base/output.test.ts +93 -0
- package/src/core/agents/base/output.ts +57 -0
- package/src/core/agents/base/types.test-d.ts +69 -0
- package/src/core/agents/base/types.ts +503 -0
- package/src/core/agents/base/utils.test.ts +397 -0
- package/src/core/agents/base/utils.ts +197 -0
- package/src/core/agents/flow/engine.test.ts +452 -0
- package/src/core/agents/flow/engine.ts +281 -0
- package/src/core/agents/flow/flow-agent.test.ts +1027 -0
- package/src/core/agents/flow/flow-agent.ts +473 -0
- package/src/core/agents/flow/messages.test.ts +198 -0
- package/src/core/agents/flow/messages.ts +141 -0
- package/src/core/agents/flow/steps/agent.test.ts +280 -0
- package/src/core/agents/flow/steps/agent.ts +87 -0
- package/src/core/agents/flow/steps/all.test.ts +300 -0
- package/src/core/agents/flow/steps/all.ts +73 -0
- package/src/core/agents/flow/steps/builder.ts +124 -0
- package/src/core/agents/flow/steps/each.test.ts +257 -0
- package/src/core/agents/flow/steps/each.ts +61 -0
- package/src/core/agents/flow/steps/factory.test-d.ts +50 -0
- package/src/core/agents/flow/steps/factory.test.ts +1025 -0
- package/src/core/agents/flow/steps/factory.ts +645 -0
- package/src/core/agents/flow/steps/map.test.ts +273 -0
- package/src/core/agents/flow/steps/map.ts +75 -0
- package/src/core/agents/flow/steps/race.test.ts +290 -0
- package/src/core/agents/flow/steps/race.ts +59 -0
- package/src/core/agents/flow/steps/reduce.test.ts +310 -0
- package/src/core/agents/flow/steps/reduce.ts +73 -0
- package/src/core/agents/flow/steps/result.ts +27 -0
- package/src/core/agents/flow/steps/step.test.ts +402 -0
- package/src/core/agents/flow/steps/step.ts +51 -0
- package/src/core/agents/flow/steps/while.test.ts +283 -0
- package/src/core/agents/flow/steps/while.ts +75 -0
- package/src/core/agents/flow/types.ts +348 -0
- package/src/core/logger.test.ts +163 -0
- package/src/core/logger.ts +152 -0
- package/src/core/models/index.test.ts +137 -0
- package/src/core/models/index.ts +152 -0
- package/src/core/models/providers/openai.ts +84 -0
- package/src/core/provider/provider.test.ts +128 -0
- package/src/core/provider/provider.ts +99 -0
- package/src/core/provider/types.ts +98 -0
- package/src/core/provider/usage.test.ts +304 -0
- package/src/core/provider/usage.ts +97 -0
- package/src/core/tool.test.ts +65 -0
- package/src/core/tool.ts +164 -0
- package/src/core/types.ts +66 -0
- package/src/index.ts +95 -0
- package/src/lib/context.test.ts +86 -0
- package/src/lib/context.ts +49 -0
- package/src/lib/hooks.test.ts +102 -0
- package/src/lib/hooks.ts +59 -0
- package/src/lib/middleware.test.ts +122 -0
- package/src/lib/middleware.ts +63 -0
- package/src/lib/runnable.test.ts +41 -0
- package/src/lib/runnable.ts +22 -0
- package/src/lib/trace.test.ts +291 -0
- package/src/lib/trace.ts +145 -0
- package/src/models/index.ts +123 -0
- package/src/models/providers/index.ts +15 -0
- package/src/models/providers/openai.ts +84 -0
- package/src/testing/context.ts +32 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/logger.ts +19 -0
- package/src/utils/attempt.test.ts +127 -0
- package/src/utils/attempt.ts +38 -0
- package/src/utils/error.test.ts +179 -0
- package/src/utils/error.ts +112 -0
- package/src/utils/resolve.test.ts +38 -0
- package/src/utils/resolve.ts +41 -0
- package/src/utils/result.test.ts +79 -0
- package/src/utils/result.ts +151 -0
- package/src/utils/zod.test.ts +69 -0
- package/src/utils/zod.ts +31 -0
- package/tsconfig.json +25 -0
- package/tsdown.config.ts +15 -0
- package/vitest.config.ts +46 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock the external provider before importing the module under test
|
|
4
|
+
const mockBaseCreateOpenRouter = vi.fn();
|
|
5
|
+
const mockProviderInstance = vi.fn();
|
|
6
|
+
|
|
7
|
+
vi.mock("@openrouter/ai-sdk-provider", () => ({
|
|
8
|
+
createOpenRouter: mockBaseCreateOpenRouter,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Must import after mocking
|
|
12
|
+
const { createOpenRouter, openrouter } = await import("@/core/provider/provider.js");
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// createOpenRouter()
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
describe("createOpenRouter()", () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
mockBaseCreateOpenRouter.mockReturnValue(mockProviderInstance);
|
|
22
|
+
vi.stubEnv("OPENROUTER_API_KEY", "env-key-123");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllEnvs();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("uses explicit apiKey when provided in options", () => {
|
|
30
|
+
createOpenRouter({ apiKey: "explicit-key" });
|
|
31
|
+
|
|
32
|
+
expect(mockBaseCreateOpenRouter).toHaveBeenCalledWith(
|
|
33
|
+
expect.objectContaining({ apiKey: "explicit-key" }),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("falls back to OPENROUTER_API_KEY env var when no apiKey in options", () => {
|
|
38
|
+
createOpenRouter();
|
|
39
|
+
|
|
40
|
+
expect(mockBaseCreateOpenRouter).toHaveBeenCalledWith(
|
|
41
|
+
expect.objectContaining({ apiKey: "env-key-123" }),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to env var when options are provided without apiKey", () => {
|
|
46
|
+
createOpenRouter({});
|
|
47
|
+
|
|
48
|
+
expect(mockBaseCreateOpenRouter).toHaveBeenCalledWith(
|
|
49
|
+
expect.objectContaining({ apiKey: "env-key-123" }),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("throws when no apiKey provided and env var is not set", () => {
|
|
54
|
+
vi.stubEnv("OPENROUTER_API_KEY", undefined as unknown as string);
|
|
55
|
+
|
|
56
|
+
expect(() => createOpenRouter()).toThrow("OPENROUTER_API_KEY environment variable is required");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("forwards additional options to the base provider", () => {
|
|
60
|
+
createOpenRouter({ apiKey: "key", baseURL: "https://custom.api" } as Record<string, unknown>);
|
|
61
|
+
|
|
62
|
+
expect(mockBaseCreateOpenRouter).toHaveBeenCalledWith(
|
|
63
|
+
expect.objectContaining({
|
|
64
|
+
apiKey: "key",
|
|
65
|
+
baseURL: "https://custom.api",
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// openrouter() — cached model factory
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
// Each test in this block uses a unique API key to invalidate the module-level
|
|
76
|
+
// cache, so call-count assertions remain isolated between tests.
|
|
77
|
+
|
|
78
|
+
describe("openrouter()", () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
vi.clearAllMocks();
|
|
81
|
+
mockProviderInstance.mockReturnValue({ modelId: "mock-model" });
|
|
82
|
+
mockBaseCreateOpenRouter.mockReturnValue(mockProviderInstance);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
vi.unstubAllEnvs();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("creates a provider and returns a language model", () => {
|
|
90
|
+
vi.stubEnv("OPENROUTER_API_KEY", "key-test-create");
|
|
91
|
+
|
|
92
|
+
const result = openrouter("openai/gpt-5.2-codex");
|
|
93
|
+
|
|
94
|
+
expect(mockBaseCreateOpenRouter).toHaveBeenCalledWith({ apiKey: "key-test-create" });
|
|
95
|
+
expect(mockProviderInstance).toHaveBeenCalledWith("openai/gpt-5.2-codex");
|
|
96
|
+
expect(result).toEqual({ modelId: "mock-model" });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reuses cached provider on subsequent calls with same api key", () => {
|
|
100
|
+
vi.stubEnv("OPENROUTER_API_KEY", "key-test-reuse");
|
|
101
|
+
|
|
102
|
+
openrouter("openai/gpt-5.2-codex");
|
|
103
|
+
openrouter("openai/gpt-5.2");
|
|
104
|
+
|
|
105
|
+
// Provider should only be created once since key is the same
|
|
106
|
+
expect(mockBaseCreateOpenRouter).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(mockProviderInstance).toHaveBeenCalledTimes(2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("creates new provider when api key changes", () => {
|
|
111
|
+
vi.stubEnv("OPENROUTER_API_KEY", "key-test-change-1");
|
|
112
|
+
openrouter("openai/gpt-5.2-codex");
|
|
113
|
+
|
|
114
|
+
vi.stubEnv("OPENROUTER_API_KEY", "key-test-change-2");
|
|
115
|
+
openrouter("openai/gpt-5.2");
|
|
116
|
+
|
|
117
|
+
expect(mockBaseCreateOpenRouter).toHaveBeenCalledTimes(2);
|
|
118
|
+
expect(mockBaseCreateOpenRouter).toHaveBeenLastCalledWith({ apiKey: "key-test-change-2" });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("throws when OPENROUTER_API_KEY is not set", () => {
|
|
122
|
+
vi.stubEnv("OPENROUTER_API_KEY", undefined as unknown as string);
|
|
123
|
+
|
|
124
|
+
expect(() => openrouter("openai/gpt-5.2-codex")).toThrow(
|
|
125
|
+
"OPENROUTER_API_KEY environment variable is required",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createOpenRouter as baseCreateOpenRouter,
|
|
3
|
+
type OpenRouterProvider,
|
|
4
|
+
type OpenRouterProviderSettings,
|
|
5
|
+
} from "@openrouter/ai-sdk-provider";
|
|
6
|
+
|
|
7
|
+
import type { ModelId } from "@/core/models/index.js";
|
|
8
|
+
import { type LanguageModel } from "@/core/provider/types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create an OpenRouter provider instance with automatic API key resolution.
|
|
12
|
+
*
|
|
13
|
+
* Falls back to the `OPENROUTER_API_KEY` environment variable when
|
|
14
|
+
* no explicit `apiKey` is provided in the options.
|
|
15
|
+
*
|
|
16
|
+
* @param options - Provider settings forwarded to `@openrouter/ai-sdk-provider`.
|
|
17
|
+
* @returns A configured {@link OpenRouterProvider} instance.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const openrouter = createOpenRouter({ apiKey: 'sk-...' })
|
|
22
|
+
* const m = openrouter('openai/gpt-5.2-codex')
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function createOpenRouter(options?: OpenRouterProviderSettings): OpenRouterProvider {
|
|
26
|
+
const apiKey = resolveApiKey(options);
|
|
27
|
+
return baseCreateOpenRouter({
|
|
28
|
+
...options,
|
|
29
|
+
apiKey,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveApiKey(options: OpenRouterProviderSettings | undefined): string {
|
|
34
|
+
if (options != null && options.apiKey != null) {
|
|
35
|
+
return options.apiKey;
|
|
36
|
+
}
|
|
37
|
+
return getOpenRouterApiKey();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a cached OpenRouter model resolver.
|
|
42
|
+
*
|
|
43
|
+
* The returned function caches the underlying provider and invalidates
|
|
44
|
+
* when the API key changes at runtime.
|
|
45
|
+
*
|
|
46
|
+
* @returns A function that resolves a model ID to a {@link LanguageModel}.
|
|
47
|
+
*
|
|
48
|
+
* @private
|
|
49
|
+
*/
|
|
50
|
+
function createCachedOpenRouter(): (modelId: ModelId) => LanguageModel {
|
|
51
|
+
const cache: { provider: OpenRouterProvider | undefined; apiKey: string | undefined } = {
|
|
52
|
+
provider: undefined,
|
|
53
|
+
apiKey: undefined,
|
|
54
|
+
};
|
|
55
|
+
return (modelId: ModelId): LanguageModel => {
|
|
56
|
+
const apiKey = getOpenRouterApiKey();
|
|
57
|
+
if (!cache.provider || cache.apiKey !== apiKey) {
|
|
58
|
+
cache.provider = baseCreateOpenRouter({ apiKey });
|
|
59
|
+
cache.apiKey = apiKey;
|
|
60
|
+
}
|
|
61
|
+
return cache.provider(modelId);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Shorthand for creating a single OpenRouter language model.
|
|
67
|
+
*
|
|
68
|
+
* Resolves the API key from the environment and returns a ready-to-use
|
|
69
|
+
* {@link LanguageModel} that can be passed directly to AI SDK functions.
|
|
70
|
+
*
|
|
71
|
+
* The provider instance is cached at module scope and reused across
|
|
72
|
+
* calls. If `OPENROUTER_API_KEY` changes at runtime, the cache is
|
|
73
|
+
* invalidated and a new provider is created.
|
|
74
|
+
*
|
|
75
|
+
* @param modelId - An OpenRouter model identifier (e.g. `"openai/gpt-5.2-codex"`).
|
|
76
|
+
* @returns A configured {@link LanguageModel} instance.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```typescript
|
|
80
|
+
* const m = openrouter('openai/gpt-5.2-codex')
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export const openrouter: (modelId: ModelId) => LanguageModel = createCachedOpenRouter();
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read the OpenRouter API key from the environment.
|
|
87
|
+
*
|
|
88
|
+
* @throws {Error} If `OPENROUTER_API_KEY` is not set.
|
|
89
|
+
*/
|
|
90
|
+
function getOpenRouterApiKey(): string {
|
|
91
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
92
|
+
if (!apiKey) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"OPENROUTER_API_KEY environment variable is required. " +
|
|
95
|
+
"Set it in your .env file or environment.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return apiKey;
|
|
99
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type LanguageModel as BaseLanguageModel } from "ai";
|
|
2
|
+
|
|
3
|
+
import type { ModelId } from "@/core/models/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* AI SDK v3 language model type.
|
|
7
|
+
*
|
|
8
|
+
* Narrowed to the v3 specification version for type safety.
|
|
9
|
+
* All models created via `openrouter()` or `wrapModel()` satisfy this type.
|
|
10
|
+
*/
|
|
11
|
+
export type LanguageModel = Extract<BaseLanguageModel, { specificationVersion: "v3" }>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Base token counts shared by raw tracking records and final output.
|
|
15
|
+
*
|
|
16
|
+
* All fields are resolved `number` (0 when absent).
|
|
17
|
+
*/
|
|
18
|
+
export interface TokenUsage {
|
|
19
|
+
/** Number of input (prompt) tokens. */
|
|
20
|
+
readonly inputTokens: number;
|
|
21
|
+
|
|
22
|
+
/** Number of output (completion) tokens. */
|
|
23
|
+
readonly outputTokens: number;
|
|
24
|
+
|
|
25
|
+
/** Total tokens (input + output). */
|
|
26
|
+
readonly totalTokens: number;
|
|
27
|
+
|
|
28
|
+
/** Tokens served from the provider's prompt cache. */
|
|
29
|
+
readonly cacheReadTokens: number;
|
|
30
|
+
|
|
31
|
+
/** Tokens written into the provider's prompt cache. */
|
|
32
|
+
readonly cacheWriteTokens: number;
|
|
33
|
+
|
|
34
|
+
/** Tokens consumed by the model's internal reasoning (e.g. o3/o4). */
|
|
35
|
+
readonly reasoningTokens: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Raw tracking record from a single AI model invocation.
|
|
40
|
+
*
|
|
41
|
+
* Fields are `number | undefined` because providers may not report all fields.
|
|
42
|
+
* Carries `modelId` so that consumers can look up pricing if needed.
|
|
43
|
+
*/
|
|
44
|
+
export interface TokenUsageRecord {
|
|
45
|
+
/**
|
|
46
|
+
* The model identifier that produced this usage
|
|
47
|
+
* (e.g. `"openai/gpt-5.2-codex"`).
|
|
48
|
+
*/
|
|
49
|
+
readonly modelId: ModelId;
|
|
50
|
+
|
|
51
|
+
/** Number of input (prompt) tokens. */
|
|
52
|
+
readonly inputTokens: number | undefined;
|
|
53
|
+
|
|
54
|
+
/** Number of output (completion) tokens. */
|
|
55
|
+
readonly outputTokens: number | undefined;
|
|
56
|
+
|
|
57
|
+
/** Total tokens (input + output). */
|
|
58
|
+
readonly totalTokens: number | undefined;
|
|
59
|
+
|
|
60
|
+
/** Tokens served from the provider's prompt cache. */
|
|
61
|
+
readonly cacheReadTokens: number | undefined;
|
|
62
|
+
|
|
63
|
+
/** Tokens written into the provider's prompt cache. */
|
|
64
|
+
readonly cacheWriteTokens: number | undefined;
|
|
65
|
+
|
|
66
|
+
/** Tokens consumed by the model's internal reasoning (e.g. o3/o4). */
|
|
67
|
+
readonly reasoningTokens: number | undefined;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Populated by the framework — identifies which component produced this usage.
|
|
71
|
+
*/
|
|
72
|
+
readonly source?: {
|
|
73
|
+
readonly workflowId?: string;
|
|
74
|
+
readonly stepId?: string;
|
|
75
|
+
readonly agentId: string;
|
|
76
|
+
readonly scope: readonly string[];
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Final agent-level usage — tokens flat, with agentId.
|
|
82
|
+
*
|
|
83
|
+
* Produced by `agentUsage()` at the end of an agent's execution.
|
|
84
|
+
*/
|
|
85
|
+
export interface AgentTokenUsage extends TokenUsage {
|
|
86
|
+
/** The agent that produced this usage. */
|
|
87
|
+
readonly agentId: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Final flow agent-level usage — per-agent breakdown.
|
|
92
|
+
*
|
|
93
|
+
* Produced by `flowAgentUsage()` at the end of a flow agent's execution.
|
|
94
|
+
*/
|
|
95
|
+
export interface FlowAgentTokenUsage {
|
|
96
|
+
/** Per-agent usage entries. */
|
|
97
|
+
readonly usages: readonly AgentTokenUsage[];
|
|
98
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { TokenUsage, TokenUsageRecord } from "@/core/provider/types.js";
|
|
4
|
+
import { agentUsage, sumTokenUsage, flowAgentUsage } from "@/core/provider/usage.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function createRecord(overrides?: Partial<TokenUsageRecord>): TokenUsageRecord {
|
|
11
|
+
return {
|
|
12
|
+
modelId: "openai/gpt-5.2-codex",
|
|
13
|
+
inputTokens: undefined,
|
|
14
|
+
outputTokens: undefined,
|
|
15
|
+
totalTokens: undefined,
|
|
16
|
+
cacheReadTokens: undefined,
|
|
17
|
+
cacheWriteTokens: undefined,
|
|
18
|
+
reasoningTokens: undefined,
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// agentUsage()
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
describe("agentUsage()", () => {
|
|
28
|
+
it("returns zero counts for a record with all undefined fields", () => {
|
|
29
|
+
const result = agentUsage("agent-1", createRecord());
|
|
30
|
+
|
|
31
|
+
expect(result).toEqual({
|
|
32
|
+
agentId: "agent-1",
|
|
33
|
+
inputTokens: 0,
|
|
34
|
+
outputTokens: 0,
|
|
35
|
+
totalTokens: 0,
|
|
36
|
+
cacheReadTokens: 0,
|
|
37
|
+
cacheWriteTokens: 0,
|
|
38
|
+
reasoningTokens: 0,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("passes through defined token counts from a single record", () => {
|
|
43
|
+
const record = createRecord({
|
|
44
|
+
inputTokens: 100,
|
|
45
|
+
outputTokens: 50,
|
|
46
|
+
totalTokens: 150,
|
|
47
|
+
cacheReadTokens: 10,
|
|
48
|
+
cacheWriteTokens: 5,
|
|
49
|
+
reasoningTokens: 20,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const result = agentUsage("agent-2", record);
|
|
53
|
+
|
|
54
|
+
expect(result.agentId).toBe("agent-2");
|
|
55
|
+
expect(result.inputTokens).toBe(100);
|
|
56
|
+
expect(result.outputTokens).toBe(50);
|
|
57
|
+
expect(result.totalTokens).toBe(150);
|
|
58
|
+
expect(result.cacheReadTokens).toBe(10);
|
|
59
|
+
expect(result.cacheWriteTokens).toBe(5);
|
|
60
|
+
expect(result.reasoningTokens).toBe(20);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("accepts a single record (not wrapped in array)", () => {
|
|
64
|
+
const record = createRecord({ inputTokens: 42 });
|
|
65
|
+
|
|
66
|
+
const result = agentUsage("agent-single", record);
|
|
67
|
+
|
|
68
|
+
expect(result.inputTokens).toBe(42);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("aggregates token counts across multiple records", () => {
|
|
72
|
+
const records: TokenUsageRecord[] = [
|
|
73
|
+
createRecord({ inputTokens: 100, outputTokens: 50, totalTokens: 150 }),
|
|
74
|
+
createRecord({ inputTokens: 200, outputTokens: 100, totalTokens: 300 }),
|
|
75
|
+
createRecord({ inputTokens: 50, outputTokens: 25, totalTokens: 75 }),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const result = agentUsage("agent-multi", records);
|
|
79
|
+
|
|
80
|
+
expect(result.inputTokens).toBe(350);
|
|
81
|
+
expect(result.outputTokens).toBe(175);
|
|
82
|
+
expect(result.totalTokens).toBe(525);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("treats undefined fields as 0 during aggregation", () => {
|
|
86
|
+
const records: TokenUsageRecord[] = [
|
|
87
|
+
createRecord({ inputTokens: 100, cacheReadTokens: undefined }),
|
|
88
|
+
createRecord({ inputTokens: undefined, cacheReadTokens: 30 }),
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const result = agentUsage("agent-mixed", records);
|
|
92
|
+
|
|
93
|
+
expect(result.inputTokens).toBe(100);
|
|
94
|
+
expect(result.cacheReadTokens).toBe(30);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns zero counts for an empty array", () => {
|
|
98
|
+
const result = agentUsage("agent-empty", []);
|
|
99
|
+
|
|
100
|
+
expect(result.inputTokens).toBe(0);
|
|
101
|
+
expect(result.outputTokens).toBe(0);
|
|
102
|
+
expect(result.totalTokens).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// flowAgentUsage()
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
describe("flowAgentUsage()", () => {
|
|
111
|
+
it("groups records by source.agentId and returns per-agent usage", () => {
|
|
112
|
+
const records: TokenUsageRecord[] = [
|
|
113
|
+
createRecord({
|
|
114
|
+
inputTokens: 100,
|
|
115
|
+
outputTokens: 50,
|
|
116
|
+
totalTokens: 150,
|
|
117
|
+
source: { agentId: "agent-a", scope: [] },
|
|
118
|
+
}),
|
|
119
|
+
createRecord({
|
|
120
|
+
inputTokens: 200,
|
|
121
|
+
outputTokens: 100,
|
|
122
|
+
totalTokens: 300,
|
|
123
|
+
source: { agentId: "agent-b", scope: [] },
|
|
124
|
+
}),
|
|
125
|
+
createRecord({
|
|
126
|
+
inputTokens: 50,
|
|
127
|
+
outputTokens: 25,
|
|
128
|
+
totalTokens: 75,
|
|
129
|
+
source: { agentId: "agent-a", scope: [] },
|
|
130
|
+
}),
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const result = flowAgentUsage(records);
|
|
134
|
+
|
|
135
|
+
expect(result.usages).toHaveLength(2);
|
|
136
|
+
|
|
137
|
+
const agentA = result.usages.find((u) => u.agentId === "agent-a");
|
|
138
|
+
if (!agentA) {
|
|
139
|
+
throw new Error("Expected agent-a usage to exist");
|
|
140
|
+
}
|
|
141
|
+
expect(agentA.inputTokens).toBe(150);
|
|
142
|
+
expect(agentA.outputTokens).toBe(75);
|
|
143
|
+
expect(agentA.totalTokens).toBe(225);
|
|
144
|
+
|
|
145
|
+
const agentB = result.usages.find((u) => u.agentId === "agent-b");
|
|
146
|
+
if (!agentB) {
|
|
147
|
+
throw new Error("Expected agent-b usage to exist");
|
|
148
|
+
}
|
|
149
|
+
expect(agentB.inputTokens).toBe(200);
|
|
150
|
+
expect(agentB.outputTokens).toBe(100);
|
|
151
|
+
expect(agentB.totalTokens).toBe(300);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('assigns records without source to "unknown" agent', () => {
|
|
155
|
+
const records: TokenUsageRecord[] = [
|
|
156
|
+
createRecord({ inputTokens: 100 }),
|
|
157
|
+
createRecord({ inputTokens: 50 }),
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const result = flowAgentUsage(records);
|
|
161
|
+
|
|
162
|
+
expect(result.usages).toHaveLength(1);
|
|
163
|
+
const unknown = result.usages[0];
|
|
164
|
+
if (!unknown) {
|
|
165
|
+
throw new Error("Expected unknown usage to exist");
|
|
166
|
+
}
|
|
167
|
+
expect(unknown.agentId).toBe("unknown");
|
|
168
|
+
expect(unknown.inputTokens).toBe(150);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('assigns records with source but no agentId to "unknown"', () => {
|
|
172
|
+
const records: TokenUsageRecord[] = [
|
|
173
|
+
createRecord({
|
|
174
|
+
inputTokens: 75,
|
|
175
|
+
source: { agentId: undefined as unknown as string, scope: [] },
|
|
176
|
+
}),
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const result = flowAgentUsage(records);
|
|
180
|
+
|
|
181
|
+
expect(result.usages).toHaveLength(1);
|
|
182
|
+
const unknown = result.usages[0];
|
|
183
|
+
if (!unknown) {
|
|
184
|
+
throw new Error("Expected unknown usage to exist");
|
|
185
|
+
}
|
|
186
|
+
expect(unknown.agentId).toBe("unknown");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("returns empty usages array for empty records", () => {
|
|
190
|
+
const result = flowAgentUsage([]);
|
|
191
|
+
|
|
192
|
+
expect(result.usages).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("preserves workflow and step IDs in source grouping", () => {
|
|
196
|
+
const records: TokenUsageRecord[] = [
|
|
197
|
+
createRecord({
|
|
198
|
+
inputTokens: 10,
|
|
199
|
+
source: { agentId: "agent-x", workflowId: "wf-1", stepId: "step-1", scope: ["a"] },
|
|
200
|
+
}),
|
|
201
|
+
createRecord({
|
|
202
|
+
inputTokens: 20,
|
|
203
|
+
source: { agentId: "agent-x", workflowId: "wf-1", stepId: "step-2", scope: ["b"] },
|
|
204
|
+
}),
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
const result = flowAgentUsage(records);
|
|
208
|
+
|
|
209
|
+
expect(result.usages).toHaveLength(1);
|
|
210
|
+
const agentX = result.usages[0];
|
|
211
|
+
if (!agentX) {
|
|
212
|
+
throw new Error("Expected agent-x usage to exist");
|
|
213
|
+
}
|
|
214
|
+
expect(agentX.agentId).toBe("agent-x");
|
|
215
|
+
expect(agentX.inputTokens).toBe(30);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Helpers for sumTokenUsage
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
const ZERO_USAGE: TokenUsage = {
|
|
224
|
+
inputTokens: 0,
|
|
225
|
+
outputTokens: 0,
|
|
226
|
+
totalTokens: 0,
|
|
227
|
+
cacheReadTokens: 0,
|
|
228
|
+
cacheWriteTokens: 0,
|
|
229
|
+
reasoningTokens: 0,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
function createUsage(overrides?: Partial<TokenUsage>): TokenUsage {
|
|
233
|
+
return { ...ZERO_USAGE, ...overrides };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// sumTokenUsage()
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
describe("sumTokenUsage()", () => {
|
|
241
|
+
it("sums all fields across multiple usage objects", () => {
|
|
242
|
+
const usages = [
|
|
243
|
+
createUsage({ inputTokens: 100, outputTokens: 50, totalTokens: 150 }),
|
|
244
|
+
createUsage({ inputTokens: 200, outputTokens: 100, totalTokens: 300 }),
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
const result = sumTokenUsage(usages);
|
|
248
|
+
|
|
249
|
+
expect(result.inputTokens).toBe(300);
|
|
250
|
+
expect(result.outputTokens).toBe(150);
|
|
251
|
+
expect(result.totalTokens).toBe(450);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("sums cache and reasoning token fields", () => {
|
|
255
|
+
const usages = [
|
|
256
|
+
createUsage({ cacheReadTokens: 10, cacheWriteTokens: 5, reasoningTokens: 20 }),
|
|
257
|
+
createUsage({ cacheReadTokens: 30, cacheWriteTokens: 15, reasoningTokens: 40 }),
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
const result = sumTokenUsage(usages);
|
|
261
|
+
|
|
262
|
+
expect(result.cacheReadTokens).toBe(40);
|
|
263
|
+
expect(result.cacheWriteTokens).toBe(20);
|
|
264
|
+
expect(result.reasoningTokens).toBe(60);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("returns zero usage for an empty array", () => {
|
|
268
|
+
const result = sumTokenUsage([]);
|
|
269
|
+
|
|
270
|
+
expect(result).toEqual(ZERO_USAGE);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("returns the same values for a single-element array", () => {
|
|
274
|
+
const usage = createUsage({ inputTokens: 42, outputTokens: 21, totalTokens: 63 });
|
|
275
|
+
|
|
276
|
+
const result = sumTokenUsage([usage]);
|
|
277
|
+
|
|
278
|
+
expect(result).toEqual(usage);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("sums three or more usage objects", () => {
|
|
282
|
+
const usages = [
|
|
283
|
+
createUsage({ inputTokens: 100 }),
|
|
284
|
+
createUsage({ inputTokens: 200 }),
|
|
285
|
+
createUsage({ inputTokens: 50 }),
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
const result = sumTokenUsage(usages);
|
|
289
|
+
|
|
290
|
+
expect(result.inputTokens).toBe(350);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("does not mutate any input", () => {
|
|
294
|
+
const a = createUsage({ inputTokens: 100 });
|
|
295
|
+
const b = createUsage({ inputTokens: 200 });
|
|
296
|
+
const aCopy = { ...a };
|
|
297
|
+
const bCopy = { ...b };
|
|
298
|
+
|
|
299
|
+
sumTokenUsage([a, b]);
|
|
300
|
+
|
|
301
|
+
expect(a).toEqual(aCopy);
|
|
302
|
+
expect(b).toEqual(bCopy);
|
|
303
|
+
});
|
|
304
|
+
});
|