@dangao/bun-server 1.12.1 → 2.0.1
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 +32 -0
- package/dist/ai/ai-module.d.ts +24 -0
- package/dist/ai/ai-module.d.ts.map +1 -0
- package/dist/ai/decorators.d.ts +25 -0
- package/dist/ai/decorators.d.ts.map +1 -0
- package/dist/ai/errors.d.ts +39 -0
- package/dist/ai/errors.d.ts.map +1 -0
- package/dist/ai/index.d.ts +12 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/providers/anthropic-provider.d.ts +23 -0
- package/dist/ai/providers/anthropic-provider.d.ts.map +1 -0
- package/dist/ai/providers/google-provider.d.ts +20 -0
- package/dist/ai/providers/google-provider.d.ts.map +1 -0
- package/dist/ai/providers/ollama-provider.d.ts +17 -0
- package/dist/ai/providers/ollama-provider.d.ts.map +1 -0
- package/dist/ai/providers/openai-provider.d.ts +28 -0
- package/dist/ai/providers/openai-provider.d.ts.map +1 -0
- package/dist/ai/service.d.ts +40 -0
- package/dist/ai/service.d.ts.map +1 -0
- package/dist/ai/tools/tool-executor.d.ts +15 -0
- package/dist/ai/tools/tool-executor.d.ts.map +1 -0
- package/dist/ai/tools/tool-registry.d.ts +39 -0
- package/dist/ai/tools/tool-registry.d.ts.map +1 -0
- package/dist/ai/types.d.ts +134 -0
- package/dist/ai/types.d.ts.map +1 -0
- package/dist/ai-guard/ai-guard-module.d.ts +18 -0
- package/dist/ai-guard/ai-guard-module.d.ts.map +1 -0
- package/dist/ai-guard/decorators.d.ts +16 -0
- package/dist/ai-guard/decorators.d.ts.map +1 -0
- package/dist/ai-guard/detectors/content-moderator.d.ts +26 -0
- package/dist/ai-guard/detectors/content-moderator.d.ts.map +1 -0
- package/dist/ai-guard/detectors/injection-detector.d.ts +13 -0
- package/dist/ai-guard/detectors/injection-detector.d.ts.map +1 -0
- package/dist/ai-guard/detectors/pii-detector.d.ts +11 -0
- package/dist/ai-guard/detectors/pii-detector.d.ts.map +1 -0
- package/dist/ai-guard/index.d.ts +8 -0
- package/dist/ai-guard/index.d.ts.map +1 -0
- package/dist/ai-guard/service.d.ts +21 -0
- package/dist/ai-guard/service.d.ts.map +1 -0
- package/dist/ai-guard/types.d.ts +59 -0
- package/dist/ai-guard/types.d.ts.map +1 -0
- package/dist/conversation/conversation-module.d.ts +25 -0
- package/dist/conversation/conversation-module.d.ts.map +1 -0
- package/dist/conversation/decorators.d.ts +28 -0
- package/dist/conversation/decorators.d.ts.map +1 -0
- package/dist/conversation/index.d.ts +8 -0
- package/dist/conversation/index.d.ts.map +1 -0
- package/dist/conversation/service.d.ts +43 -0
- package/dist/conversation/service.d.ts.map +1 -0
- package/dist/conversation/stores/database-store.d.ts +46 -0
- package/dist/conversation/stores/database-store.d.ts.map +1 -0
- package/dist/conversation/stores/memory-store.d.ts +17 -0
- package/dist/conversation/stores/memory-store.d.ts.map +1 -0
- package/dist/conversation/stores/redis-store.d.ts +39 -0
- package/dist/conversation/stores/redis-store.d.ts.map +1 -0
- package/dist/conversation/types.d.ts +64 -0
- package/dist/conversation/types.d.ts.map +1 -0
- package/dist/embedding/embedding-module.d.ts +20 -0
- package/dist/embedding/embedding-module.d.ts.map +1 -0
- package/dist/embedding/index.d.ts +6 -0
- package/dist/embedding/index.d.ts.map +1 -0
- package/dist/embedding/providers/ollama-embedding-provider.d.ts +18 -0
- package/dist/embedding/providers/ollama-embedding-provider.d.ts.map +1 -0
- package/dist/embedding/providers/openai-embedding-provider.d.ts +18 -0
- package/dist/embedding/providers/openai-embedding-provider.d.ts.map +1 -0
- package/dist/embedding/service.d.ts +27 -0
- package/dist/embedding/service.d.ts.map +1 -0
- package/dist/embedding/types.d.ts +25 -0
- package/dist/embedding/types.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2638 -1
- package/dist/mcp/decorators.d.ts +42 -0
- package/dist/mcp/decorators.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +6 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/mcp-module.d.ts +22 -0
- package/dist/mcp/mcp-module.d.ts.map +1 -0
- package/dist/mcp/registry.d.ts +23 -0
- package/dist/mcp/registry.d.ts.map +1 -0
- package/dist/mcp/server.d.ts +29 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/types.d.ts +60 -0
- package/dist/mcp/types.d.ts.map +1 -0
- package/dist/prompt/index.d.ts +6 -0
- package/dist/prompt/index.d.ts.map +1 -0
- package/dist/prompt/prompt-module.d.ts +23 -0
- package/dist/prompt/prompt-module.d.ts.map +1 -0
- package/dist/prompt/service.d.ts +47 -0
- package/dist/prompt/service.d.ts.map +1 -0
- package/dist/prompt/stores/file-store.d.ts +36 -0
- package/dist/prompt/stores/file-store.d.ts.map +1 -0
- package/dist/prompt/stores/memory-store.d.ts +17 -0
- package/dist/prompt/stores/memory-store.d.ts.map +1 -0
- package/dist/prompt/types.d.ts +68 -0
- package/dist/prompt/types.d.ts.map +1 -0
- package/dist/rag/chunkers/markdown-chunker.d.ts +11 -0
- package/dist/rag/chunkers/markdown-chunker.d.ts.map +1 -0
- package/dist/rag/chunkers/text-chunker.d.ts +11 -0
- package/dist/rag/chunkers/text-chunker.d.ts.map +1 -0
- package/dist/rag/decorators.d.ts +24 -0
- package/dist/rag/decorators.d.ts.map +1 -0
- package/dist/rag/index.d.ts +7 -0
- package/dist/rag/index.d.ts.map +1 -0
- package/dist/rag/rag-module.d.ts +23 -0
- package/dist/rag/rag-module.d.ts.map +1 -0
- package/dist/rag/service.d.ts +36 -0
- package/dist/rag/service.d.ts.map +1 -0
- package/dist/rag/types.d.ts +56 -0
- package/dist/rag/types.d.ts.map +1 -0
- package/dist/vector-store/index.d.ts +6 -0
- package/dist/vector-store/index.d.ts.map +1 -0
- package/dist/vector-store/stores/memory-store.d.ts +17 -0
- package/dist/vector-store/stores/memory-store.d.ts.map +1 -0
- package/dist/vector-store/stores/pinecone-store.d.ts +27 -0
- package/dist/vector-store/stores/pinecone-store.d.ts.map +1 -0
- package/dist/vector-store/stores/qdrant-store.d.ts +29 -0
- package/dist/vector-store/stores/qdrant-store.d.ts.map +1 -0
- package/dist/vector-store/types.d.ts +60 -0
- package/dist/vector-store/types.d.ts.map +1 -0
- package/dist/vector-store/vector-store-module.d.ts +20 -0
- package/dist/vector-store/vector-store-module.d.ts.map +1 -0
- package/docs/ai.md +500 -0
- package/docs/best-practices.md +83 -8
- package/docs/database.md +23 -0
- package/docs/guide.md +90 -27
- package/docs/migration.md +81 -7
- package/docs/security.md +23 -0
- package/docs/zh/ai.md +441 -0
- package/docs/zh/best-practices.md +43 -0
- package/docs/zh/database.md +23 -0
- package/docs/zh/guide.md +40 -1
- package/docs/zh/migration.md +39 -0
- package/docs/zh/security.md +23 -0
- package/package.json +3 -3
- package/src/ai/ai-module.ts +62 -0
- package/src/ai/decorators.ts +30 -0
- package/src/ai/errors.ts +71 -0
- package/src/ai/index.ts +11 -0
- package/src/ai/providers/anthropic-provider.ts +190 -0
- package/src/ai/providers/google-provider.ts +179 -0
- package/src/ai/providers/ollama-provider.ts +126 -0
- package/src/ai/providers/openai-provider.ts +242 -0
- package/src/ai/service.ts +155 -0
- package/src/ai/tools/tool-executor.ts +38 -0
- package/src/ai/tools/tool-registry.ts +91 -0
- package/src/ai/types.ts +145 -0
- package/src/ai-guard/ai-guard-module.ts +50 -0
- package/src/ai-guard/decorators.ts +21 -0
- package/src/ai-guard/detectors/content-moderator.ts +80 -0
- package/src/ai-guard/detectors/injection-detector.ts +48 -0
- package/src/ai-guard/detectors/pii-detector.ts +64 -0
- package/src/ai-guard/index.ts +7 -0
- package/src/ai-guard/service.ts +100 -0
- package/src/ai-guard/types.ts +61 -0
- package/src/conversation/conversation-module.ts +63 -0
- package/src/conversation/decorators.ts +47 -0
- package/src/conversation/index.ts +7 -0
- package/src/conversation/service.ts +133 -0
- package/src/conversation/stores/database-store.ts +125 -0
- package/src/conversation/stores/memory-store.ts +57 -0
- package/src/conversation/stores/redis-store.ts +101 -0
- package/src/conversation/types.ts +68 -0
- package/src/embedding/embedding-module.ts +52 -0
- package/src/embedding/index.ts +5 -0
- package/src/embedding/providers/ollama-embedding-provider.ts +39 -0
- package/src/embedding/providers/openai-embedding-provider.ts +47 -0
- package/src/embedding/service.ts +55 -0
- package/src/embedding/types.ts +27 -0
- package/src/index.ts +10 -0
- package/src/mcp/decorators.ts +60 -0
- package/src/mcp/index.ts +5 -0
- package/src/mcp/mcp-module.ts +58 -0
- package/src/mcp/registry.ts +72 -0
- package/src/mcp/server.ts +164 -0
- package/src/mcp/types.ts +63 -0
- package/src/prompt/index.ts +5 -0
- package/src/prompt/prompt-module.ts +61 -0
- package/src/prompt/service.ts +93 -0
- package/src/prompt/stores/file-store.ts +135 -0
- package/src/prompt/stores/memory-store.ts +82 -0
- package/src/prompt/types.ts +84 -0
- package/src/rag/chunkers/markdown-chunker.ts +40 -0
- package/src/rag/chunkers/text-chunker.ts +30 -0
- package/src/rag/decorators.ts +26 -0
- package/src/rag/index.ts +6 -0
- package/src/rag/rag-module.ts +78 -0
- package/src/rag/service.ts +134 -0
- package/src/rag/types.ts +47 -0
- package/src/vector-store/index.ts +5 -0
- package/src/vector-store/stores/memory-store.ts +69 -0
- package/src/vector-store/stores/pinecone-store.ts +123 -0
- package/src/vector-store/stores/qdrant-store.ts +147 -0
- package/src/vector-store/types.ts +77 -0
- package/src/vector-store/vector-store-module.ts +50 -0
- package/tests/ai/ai-module.test.ts +46 -0
- package/tests/ai/ai-service.test.ts +91 -0
- package/tests/ai/tool-registry.test.ts +57 -0
- package/tests/ai-guard/ai-guard-module.test.ts +23 -0
- package/tests/ai-guard/content-moderator.test.ts +65 -0
- package/tests/ai-guard/pii-detector.test.ts +41 -0
- package/tests/conversation/conversation-module.test.ts +26 -0
- package/tests/conversation/conversation-service.test.ts +64 -0
- package/tests/conversation/memory-store.test.ts +68 -0
- package/tests/embedding/embedding-service.test.ts +55 -0
- package/tests/mcp/mcp-server.test.ts +85 -0
- package/tests/prompt/prompt-module.test.ts +30 -0
- package/tests/prompt/prompt-service.test.ts +74 -0
- package/tests/rag/chunkers.test.ts +58 -0
- package/tests/rag/rag-service.test.ts +66 -0
- package/tests/vector-store/memory-vector-store.test.ts +84 -0
- package/tests/interceptor/perf/interceptor-performance.test.ts +0 -340
- package/tests/perf/optimization.test.ts +0 -182
- package/tests/perf/regression.test.ts +0 -120
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Module, MODULE_METADATA_KEY } from '../di/module';
|
|
2
|
+
import type { ModuleProvider } from '../di/module';
|
|
3
|
+
import { AiService } from './service';
|
|
4
|
+
import {
|
|
5
|
+
AI_SERVICE_TOKEN,
|
|
6
|
+
AI_MODULE_OPTIONS_TOKEN,
|
|
7
|
+
AI_TOOL_REGISTRY_TOKEN,
|
|
8
|
+
type AiModuleOptions,
|
|
9
|
+
} from './types';
|
|
10
|
+
import { ToolRegistry } from './tools/tool-registry';
|
|
11
|
+
|
|
12
|
+
@Module({ providers: [] })
|
|
13
|
+
export class AiModule {
|
|
14
|
+
/**
|
|
15
|
+
* Configure the AI module with one or more LLM providers.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* AiModule.forRoot({
|
|
20
|
+
* providers: [
|
|
21
|
+
* { name: 'openai', provider: OpenAIProvider, config: { apiKey: '...' }, default: true },
|
|
22
|
+
* { name: 'ollama', provider: OllamaProvider, config: {} },
|
|
23
|
+
* ],
|
|
24
|
+
* fallback: true,
|
|
25
|
+
* timeout: 30000,
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
public static forRoot(options: AiModuleOptions): typeof AiModule {
|
|
30
|
+
const toolRegistry = new ToolRegistry();
|
|
31
|
+
const aiService = new AiService(options);
|
|
32
|
+
aiService.setToolRegistry(toolRegistry);
|
|
33
|
+
|
|
34
|
+
const providers: ModuleProvider[] = [
|
|
35
|
+
{ provide: AI_MODULE_OPTIONS_TOKEN, useValue: options },
|
|
36
|
+
{ provide: AI_SERVICE_TOKEN, useValue: aiService },
|
|
37
|
+
{ provide: AI_TOOL_REGISTRY_TOKEN, useValue: toolRegistry },
|
|
38
|
+
AiService,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const existing = Reflect.getMetadata(MODULE_METADATA_KEY, AiModule) || {};
|
|
42
|
+
Reflect.defineMetadata(MODULE_METADATA_KEY, {
|
|
43
|
+
...existing,
|
|
44
|
+
providers: [...(existing.providers || []), ...providers],
|
|
45
|
+
exports: [
|
|
46
|
+
...(existing.exports || []),
|
|
47
|
+
AI_SERVICE_TOKEN,
|
|
48
|
+
AI_TOOL_REGISTRY_TOKEN,
|
|
49
|
+
AiService,
|
|
50
|
+
],
|
|
51
|
+
}, AiModule);
|
|
52
|
+
|
|
53
|
+
return AiModule;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reset module state (for testing)
|
|
58
|
+
*/
|
|
59
|
+
public static reset(): void {
|
|
60
|
+
Reflect.deleteMetadata(MODULE_METADATA_KEY, AiModule);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AiToolDefinition } from './types';
|
|
2
|
+
import { AI_TOOL_METADATA_KEY } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Mark a service method as an AI tool available for Tool Calling.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* \@Injectable()
|
|
10
|
+
* class CalculatorService {
|
|
11
|
+
* \@AiTool({
|
|
12
|
+
* name: 'calculate',
|
|
13
|
+
* description: 'Evaluate a mathematical expression',
|
|
14
|
+
* parameters: {
|
|
15
|
+
* type: 'object',
|
|
16
|
+
* properties: { expression: { type: 'string' } },
|
|
17
|
+
* required: ['expression'],
|
|
18
|
+
* },
|
|
19
|
+
* })
|
|
20
|
+
* public calculate({ expression }: { expression: string }): string {
|
|
21
|
+
* return String(eval(expression));
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function AiTool(definition: AiToolDefinition): MethodDecorator {
|
|
27
|
+
return (target, propertyKey) => {
|
|
28
|
+
Reflect.defineMetadata(AI_TOOL_METADATA_KEY, definition, target, propertyKey);
|
|
29
|
+
};
|
|
30
|
+
}
|
package/src/ai/errors.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { HttpException } from '../error/http-exception';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base AI provider error
|
|
5
|
+
*/
|
|
6
|
+
export class AiProviderError extends HttpException {
|
|
7
|
+
public constructor(
|
|
8
|
+
message: string,
|
|
9
|
+
public readonly provider: string,
|
|
10
|
+
statusCode = 502,
|
|
11
|
+
) {
|
|
12
|
+
super(statusCode, `[${provider}] ${message}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* LLM rate limit exceeded
|
|
18
|
+
*/
|
|
19
|
+
export class AiRateLimitError extends AiProviderError {
|
|
20
|
+
public constructor(provider: string, retryAfterMs?: number) {
|
|
21
|
+
super(
|
|
22
|
+
retryAfterMs
|
|
23
|
+
? `Rate limit exceeded. Retry after ${retryAfterMs}ms`
|
|
24
|
+
: 'Rate limit exceeded',
|
|
25
|
+
provider,
|
|
26
|
+
429,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Input exceeds model context window
|
|
33
|
+
*/
|
|
34
|
+
export class AiContextLengthError extends AiProviderError {
|
|
35
|
+
public constructor(provider: string, maxTokens?: number) {
|
|
36
|
+
super(
|
|
37
|
+
maxTokens
|
|
38
|
+
? `Context length exceeded (max ${maxTokens} tokens)`
|
|
39
|
+
: 'Context length exceeded',
|
|
40
|
+
provider,
|
|
41
|
+
413,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* LLM request timed out
|
|
48
|
+
*/
|
|
49
|
+
export class AiTimeoutError extends AiProviderError {
|
|
50
|
+
public constructor(provider: string, timeoutMs: number) {
|
|
51
|
+
super(`Request timed out after ${timeoutMs}ms`, provider, 504);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* No AI providers configured
|
|
57
|
+
*/
|
|
58
|
+
export class AiNoProviderError extends HttpException {
|
|
59
|
+
public constructor() {
|
|
60
|
+
super(500, 'No AI providers configured. Call AiModule.forRoot() first.');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* All providers in fallback chain failed
|
|
66
|
+
*/
|
|
67
|
+
export class AiAllProvidersFailed extends HttpException {
|
|
68
|
+
public constructor(errors: string[]) {
|
|
69
|
+
super(502, `All AI providers failed: ${errors.join('; ')}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/ai/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './types';
|
|
2
|
+
export * from './errors';
|
|
3
|
+
export * from './decorators';
|
|
4
|
+
export * from './service';
|
|
5
|
+
export * from './ai-module';
|
|
6
|
+
export * from './tools/tool-registry';
|
|
7
|
+
export * from './tools/tool-executor';
|
|
8
|
+
export * from './providers/openai-provider';
|
|
9
|
+
export * from './providers/anthropic-provider';
|
|
10
|
+
export * from './providers/ollama-provider';
|
|
11
|
+
export * from './providers/google-provider';
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { LlmProvider, AiRequest, AiResponse, AiMessage } from '../types';
|
|
2
|
+
import { AiProviderError, AiRateLimitError, AiContextLengthError } from '../errors';
|
|
3
|
+
|
|
4
|
+
export interface AnthropicProviderConfig {
|
|
5
|
+
apiKey: string;
|
|
6
|
+
/** Default: https://api.anthropic.com */
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
/** Default: claude-3-7-sonnet-20250219 */
|
|
9
|
+
defaultModel?: string;
|
|
10
|
+
/** API version header */
|
|
11
|
+
anthropicVersion?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class AnthropicProvider implements LlmProvider {
|
|
15
|
+
public readonly name = 'anthropic';
|
|
16
|
+
private readonly apiKey: string;
|
|
17
|
+
private readonly baseUrl: string;
|
|
18
|
+
private readonly defaultModel: string;
|
|
19
|
+
private readonly anthropicVersion: string;
|
|
20
|
+
|
|
21
|
+
public constructor(config: AnthropicProviderConfig) {
|
|
22
|
+
this.apiKey = config.apiKey;
|
|
23
|
+
this.baseUrl = (config.baseUrl ?? 'https://api.anthropic.com').replace(/\/$/, '');
|
|
24
|
+
this.defaultModel = config.defaultModel ?? 'claude-3-7-sonnet-20250219';
|
|
25
|
+
this.anthropicVersion = config.anthropicVersion ?? '2023-06-01';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public async complete(request: AiRequest): Promise<AiResponse> {
|
|
29
|
+
const model = request.model ?? this.defaultModel;
|
|
30
|
+
|
|
31
|
+
// Separate system message from user/assistant messages
|
|
32
|
+
const systemMessages = request.messages.filter((m) => m.role === 'system');
|
|
33
|
+
const chatMessages = request.messages.filter((m) => m.role !== 'system');
|
|
34
|
+
|
|
35
|
+
const body: Record<string, unknown> = {
|
|
36
|
+
model,
|
|
37
|
+
messages: chatMessages.map((m) => ({
|
|
38
|
+
role: m.role === 'tool' ? 'user' : m.role,
|
|
39
|
+
content: m.role === 'tool'
|
|
40
|
+
? [{ type: 'tool_result', tool_use_id: m.toolCallId, content: m.content }]
|
|
41
|
+
: m.content,
|
|
42
|
+
})),
|
|
43
|
+
max_tokens: request.maxTokens ?? 4096,
|
|
44
|
+
temperature: request.temperature,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (systemMessages.length > 0) {
|
|
48
|
+
body['system'] = systemMessages.map((m) => m.content).join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (request.tools && request.tools.length > 0) {
|
|
52
|
+
body['tools'] = request.tools.map((t) => ({
|
|
53
|
+
name: t.name,
|
|
54
|
+
description: t.description,
|
|
55
|
+
input_schema: t.parameters,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const response = await this.post('/v1/messages', body);
|
|
60
|
+
const usage = (response['usage'] as { input_tokens: number; output_tokens: number }) ?? { input_tokens: 0, output_tokens: 0 };
|
|
61
|
+
|
|
62
|
+
let content = '';
|
|
63
|
+
const toolCalls: AiResponse['toolCalls'] = [];
|
|
64
|
+
const contentBlocks = response['content'] as Array<Record<string, unknown>>;
|
|
65
|
+
|
|
66
|
+
for (const block of contentBlocks) {
|
|
67
|
+
if (block['type'] === 'text') content += block['text'] as string;
|
|
68
|
+
else if (block['type'] === 'tool_use') {
|
|
69
|
+
toolCalls.push({
|
|
70
|
+
id: block['id'] as string,
|
|
71
|
+
name: block['name'] as string,
|
|
72
|
+
arguments: block['input'] as Record<string, unknown>,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
content,
|
|
79
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
80
|
+
model,
|
|
81
|
+
provider: this.name,
|
|
82
|
+
usage: {
|
|
83
|
+
promptTokens: usage.input_tokens,
|
|
84
|
+
completionTokens: usage.output_tokens,
|
|
85
|
+
totalTokens: usage.input_tokens + usage.output_tokens,
|
|
86
|
+
},
|
|
87
|
+
finishReason: response['stop_reason'] === 'tool_use' ? 'tool_calls' : 'stop',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public stream(request: AiRequest): ReadableStream<Uint8Array> {
|
|
92
|
+
const model = request.model ?? this.defaultModel;
|
|
93
|
+
const systemMessages = request.messages.filter((m) => m.role === 'system');
|
|
94
|
+
const chatMessages = request.messages.filter((m) => m.role !== 'system');
|
|
95
|
+
|
|
96
|
+
const body: Record<string, unknown> = {
|
|
97
|
+
model,
|
|
98
|
+
messages: chatMessages.map((m) => ({ role: m.role, content: m.content })),
|
|
99
|
+
max_tokens: request.maxTokens ?? 4096,
|
|
100
|
+
temperature: request.temperature,
|
|
101
|
+
stream: true,
|
|
102
|
+
};
|
|
103
|
+
if (systemMessages.length > 0) {
|
|
104
|
+
body['system'] = systemMessages.map((m) => m.content).join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const apiKey = this.apiKey;
|
|
108
|
+
const baseUrl = this.baseUrl;
|
|
109
|
+
const anthropicVersion = this.anthropicVersion;
|
|
110
|
+
const encoder = new TextEncoder();
|
|
111
|
+
|
|
112
|
+
return new ReadableStream<Uint8Array>({
|
|
113
|
+
async start(controller) {
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(`${baseUrl}/v1/messages`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: {
|
|
118
|
+
'Content-Type': 'application/json',
|
|
119
|
+
'x-api-key': apiKey,
|
|
120
|
+
'anthropic-version': anthropicVersion,
|
|
121
|
+
},
|
|
122
|
+
body: JSON.stringify(body),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!res.ok || !res.body) {
|
|
126
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: await res.text(), done: true })}\n\n`));
|
|
127
|
+
controller.close();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const reader = res.body.getReader();
|
|
132
|
+
const dec = new TextDecoder();
|
|
133
|
+
let buf = '';
|
|
134
|
+
|
|
135
|
+
while (true) {
|
|
136
|
+
const { done, value } = await reader.read();
|
|
137
|
+
if (done) break;
|
|
138
|
+
buf += dec.decode(value, { stream: true });
|
|
139
|
+
|
|
140
|
+
const lines = buf.split('\n');
|
|
141
|
+
buf = lines.pop() ?? '';
|
|
142
|
+
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
if (line.startsWith('data: ')) {
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(line.slice(6));
|
|
147
|
+
if (parsed.type === 'content_block_delta') {
|
|
148
|
+
controller.enqueue(encoder.encode(
|
|
149
|
+
`data: ${JSON.stringify({ content: parsed.delta?.text ?? '', done: false })}\n\n`,
|
|
150
|
+
));
|
|
151
|
+
} else if (parsed.type === 'message_stop') {
|
|
152
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`));
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// skip
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: String(err), done: true })}\n\n`));
|
|
162
|
+
} finally {
|
|
163
|
+
controller.close();
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
public countTokens(messages: AiMessage[]): number {
|
|
170
|
+
return Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async post(path: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
174
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: {
|
|
177
|
+
'Content-Type': 'application/json',
|
|
178
|
+
'x-api-key': this.apiKey,
|
|
179
|
+
'anthropic-version': this.anthropicVersion,
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify(body),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (res.status === 429) throw new AiRateLimitError(this.name);
|
|
185
|
+
if (res.status === 413) throw new AiContextLengthError(this.name);
|
|
186
|
+
if (!res.ok) throw new AiProviderError(await res.text(), this.name, res.status);
|
|
187
|
+
|
|
188
|
+
return res.json() as Promise<Record<string, unknown>>;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { LlmProvider, AiRequest, AiResponse, AiMessage } from '../types';
|
|
2
|
+
import { AiProviderError, AiRateLimitError } from '../errors';
|
|
3
|
+
|
|
4
|
+
export interface GoogleProviderConfig {
|
|
5
|
+
apiKey: string;
|
|
6
|
+
/** Default: gemini-2.0-flash */
|
|
7
|
+
defaultModel?: string;
|
|
8
|
+
/** Default: https://generativelanguage.googleapis.com/v1beta */
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class GoogleProvider implements LlmProvider {
|
|
13
|
+
public readonly name = 'google';
|
|
14
|
+
private readonly apiKey: string;
|
|
15
|
+
private readonly defaultModel: string;
|
|
16
|
+
private readonly baseUrl: string;
|
|
17
|
+
|
|
18
|
+
public constructor(config: GoogleProviderConfig) {
|
|
19
|
+
this.apiKey = config.apiKey;
|
|
20
|
+
this.defaultModel = config.defaultModel ?? 'gemini-2.0-flash';
|
|
21
|
+
this.baseUrl = (config.baseUrl ?? 'https://generativelanguage.googleapis.com/v1beta').replace(/\/$/, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public async complete(request: AiRequest): Promise<AiResponse> {
|
|
25
|
+
const model = request.model ?? this.defaultModel;
|
|
26
|
+
const { contents, systemInstruction } = this.toGeminiMessages(request.messages);
|
|
27
|
+
|
|
28
|
+
const body: Record<string, unknown> = {
|
|
29
|
+
contents,
|
|
30
|
+
generationConfig: {
|
|
31
|
+
temperature: request.temperature,
|
|
32
|
+
maxOutputTokens: request.maxTokens,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
if (systemInstruction) body['system_instruction'] = { parts: [{ text: systemInstruction }] };
|
|
36
|
+
|
|
37
|
+
if (request.tools && request.tools.length > 0) {
|
|
38
|
+
body['tools'] = [{
|
|
39
|
+
functionDeclarations: request.tools.map((t) => ({
|
|
40
|
+
name: t.name,
|
|
41
|
+
description: t.description,
|
|
42
|
+
parameters: t.parameters,
|
|
43
|
+
})),
|
|
44
|
+
}];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const res = await fetch(
|
|
48
|
+
`${this.baseUrl}/models/${model}:generateContent?key=${this.apiKey}`,
|
|
49
|
+
{
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
body: JSON.stringify(body),
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (res.status === 429) throw new AiRateLimitError(this.name);
|
|
57
|
+
if (!res.ok) throw new AiProviderError(await res.text(), this.name, res.status);
|
|
58
|
+
|
|
59
|
+
const data = await res.json() as Record<string, unknown>;
|
|
60
|
+
const candidate = (data['candidates'] as Array<Record<string, unknown>>)?.[0];
|
|
61
|
+
const parts = (candidate?.['content'] as Record<string, unknown>)?.['parts'] as Array<Record<string, unknown>> ?? [];
|
|
62
|
+
|
|
63
|
+
let content = '';
|
|
64
|
+
const toolCalls: AiResponse['toolCalls'] = [];
|
|
65
|
+
for (const part of parts) {
|
|
66
|
+
if (part['text']) content += part['text'] as string;
|
|
67
|
+
else if (part['functionCall']) {
|
|
68
|
+
const fc = part['functionCall'] as Record<string, unknown>;
|
|
69
|
+
toolCalls.push({
|
|
70
|
+
id: `fc-${Date.now()}`,
|
|
71
|
+
name: fc['name'] as string,
|
|
72
|
+
arguments: fc['args'] as Record<string, unknown>,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const usageMeta = data['usageMetadata'] as Record<string, number> ?? {};
|
|
78
|
+
return {
|
|
79
|
+
content,
|
|
80
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
81
|
+
model,
|
|
82
|
+
provider: this.name,
|
|
83
|
+
usage: {
|
|
84
|
+
promptTokens: usageMeta['promptTokenCount'] ?? 0,
|
|
85
|
+
completionTokens: usageMeta['candidatesTokenCount'] ?? 0,
|
|
86
|
+
totalTokens: usageMeta['totalTokenCount'] ?? 0,
|
|
87
|
+
},
|
|
88
|
+
finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public stream(request: AiRequest): ReadableStream<Uint8Array> {
|
|
93
|
+
const model = request.model ?? this.defaultModel;
|
|
94
|
+
const { contents, systemInstruction } = this.toGeminiMessages(request.messages);
|
|
95
|
+
const apiKey = this.apiKey;
|
|
96
|
+
const baseUrl = this.baseUrl;
|
|
97
|
+
const encoder = new TextEncoder();
|
|
98
|
+
|
|
99
|
+
const body: Record<string, unknown> = {
|
|
100
|
+
contents,
|
|
101
|
+
generationConfig: { temperature: request.temperature, maxOutputTokens: request.maxTokens },
|
|
102
|
+
};
|
|
103
|
+
if (systemInstruction) body['system_instruction'] = { parts: [{ text: systemInstruction }] };
|
|
104
|
+
|
|
105
|
+
return new ReadableStream<Uint8Array>({
|
|
106
|
+
async start(controller) {
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(
|
|
109
|
+
`${baseUrl}/models/${model}:streamGenerateContent?key=${apiKey}&alt=sse`,
|
|
110
|
+
{
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (!res.ok || !res.body) {
|
|
118
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: await res.text(), done: true })}\n\n`));
|
|
119
|
+
controller.close();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const reader = res.body.getReader();
|
|
124
|
+
const dec = new TextDecoder();
|
|
125
|
+
let buf = '';
|
|
126
|
+
|
|
127
|
+
while (true) {
|
|
128
|
+
const { done, value } = await reader.read();
|
|
129
|
+
if (done) break;
|
|
130
|
+
buf += dec.decode(value, { stream: true });
|
|
131
|
+
const lines = buf.split('\n');
|
|
132
|
+
buf = lines.pop() ?? '';
|
|
133
|
+
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
if (line.startsWith('data: ')) {
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(line.slice(6)) as Record<string, unknown>;
|
|
138
|
+
const candidate = (parsed['candidates'] as Array<Record<string, unknown>>)?.[0];
|
|
139
|
+
const parts = (candidate?.['content'] as Record<string, unknown>)?.['parts'] as Array<Record<string, unknown>> ?? [];
|
|
140
|
+
const text = parts.map((p) => p['text'] ?? '').join('');
|
|
141
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content: text, done: false })}\n\n`));
|
|
142
|
+
} catch {
|
|
143
|
+
// skip
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`));
|
|
149
|
+
} catch (err) {
|
|
150
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: String(err), done: true })}\n\n`));
|
|
151
|
+
} finally {
|
|
152
|
+
controller.close();
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public countTokens(messages: AiMessage[]): number {
|
|
159
|
+
return Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private toGeminiMessages(messages: AiMessage[]): {
|
|
163
|
+
contents: unknown[];
|
|
164
|
+
systemInstruction?: string;
|
|
165
|
+
} {
|
|
166
|
+
const systemParts = messages.filter((m) => m.role === 'system').map((m) => m.content);
|
|
167
|
+
const chatMessages = messages.filter((m) => m.role !== 'system');
|
|
168
|
+
|
|
169
|
+
const contents = chatMessages.map((m) => ({
|
|
170
|
+
role: m.role === 'assistant' ? 'model' : 'user',
|
|
171
|
+
parts: [{ text: m.content }],
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
contents,
|
|
176
|
+
systemInstruction: systemParts.length > 0 ? systemParts.join('\n') : undefined,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { LlmProvider, AiRequest, AiResponse, AiMessage } from '../types';
|
|
2
|
+
import { AiProviderError } from '../errors';
|
|
3
|
+
|
|
4
|
+
export interface OllamaProviderConfig {
|
|
5
|
+
/** Default: http://localhost:11434 */
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
/** Default model to use */
|
|
8
|
+
defaultModel?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class OllamaProvider implements LlmProvider {
|
|
12
|
+
public readonly name = 'ollama';
|
|
13
|
+
private readonly baseUrl: string;
|
|
14
|
+
private readonly defaultModel: string;
|
|
15
|
+
|
|
16
|
+
public constructor(config: OllamaProviderConfig = {}) {
|
|
17
|
+
this.baseUrl = (config.baseUrl ?? 'http://localhost:11434').replace(/\/$/, '');
|
|
18
|
+
this.defaultModel = config.defaultModel ?? 'llama3.2';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public async complete(request: AiRequest): Promise<AiResponse> {
|
|
22
|
+
const model = request.model ?? this.defaultModel;
|
|
23
|
+
|
|
24
|
+
const res = await fetch(`${this.baseUrl}/api/chat`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
model,
|
|
29
|
+
messages: request.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
30
|
+
stream: false,
|
|
31
|
+
options: {
|
|
32
|
+
temperature: request.temperature,
|
|
33
|
+
num_predict: request.maxTokens,
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
throw new AiProviderError(await res.text(), this.name, res.status);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const data = await res.json() as Record<string, unknown>;
|
|
43
|
+
const message = data['message'] as Record<string, unknown>;
|
|
44
|
+
const evalCount = (data['eval_count'] as number) ?? 0;
|
|
45
|
+
const promptEvalCount = (data['prompt_eval_count'] as number) ?? 0;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
content: (message['content'] as string) ?? '',
|
|
49
|
+
model,
|
|
50
|
+
provider: this.name,
|
|
51
|
+
usage: {
|
|
52
|
+
promptTokens: promptEvalCount,
|
|
53
|
+
completionTokens: evalCount,
|
|
54
|
+
totalTokens: promptEvalCount + evalCount,
|
|
55
|
+
},
|
|
56
|
+
finishReason: 'stop',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public stream(request: AiRequest): ReadableStream<Uint8Array> {
|
|
61
|
+
const model = request.model ?? this.defaultModel;
|
|
62
|
+
const baseUrl = this.baseUrl;
|
|
63
|
+
const encoder = new TextEncoder();
|
|
64
|
+
|
|
65
|
+
return new ReadableStream<Uint8Array>({
|
|
66
|
+
async start(controller) {
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(`${baseUrl}/api/chat`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
model,
|
|
73
|
+
messages: request.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
74
|
+
stream: true,
|
|
75
|
+
options: {
|
|
76
|
+
temperature: request.temperature,
|
|
77
|
+
num_predict: request.maxTokens,
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!res.ok || !res.body) {
|
|
83
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: await res.text(), done: true })}\n\n`));
|
|
84
|
+
controller.close();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const reader = res.body.getReader();
|
|
89
|
+
const dec = new TextDecoder();
|
|
90
|
+
let buf = '';
|
|
91
|
+
|
|
92
|
+
while (true) {
|
|
93
|
+
const { done, value } = await reader.read();
|
|
94
|
+
if (done) break;
|
|
95
|
+
buf += dec.decode(value, { stream: true });
|
|
96
|
+
|
|
97
|
+
const lines = buf.split('\n');
|
|
98
|
+
buf = lines.pop() ?? '';
|
|
99
|
+
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
if (!line.trim()) continue;
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
104
|
+
const msgContent = (parsed['message'] as Record<string, unknown>)?.['content'] as string ?? '';
|
|
105
|
+
const isDone = Boolean(parsed['done']);
|
|
106
|
+
controller.enqueue(encoder.encode(
|
|
107
|
+
`data: ${JSON.stringify({ content: msgContent, done: isDone })}\n\n`,
|
|
108
|
+
));
|
|
109
|
+
} catch {
|
|
110
|
+
// skip
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: String(err), done: true })}\n\n`));
|
|
116
|
+
} finally {
|
|
117
|
+
controller.close();
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public countTokens(messages: AiMessage[]): number {
|
|
124
|
+
return Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
|
|
125
|
+
}
|
|
126
|
+
}
|