@charming_groot/providers 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/dist/auth/auth-resolver.d.ts +9 -0
- package/dist/auth/auth-resolver.d.ts.map +1 -0
- package/dist/auth/auth-resolver.js +200 -0
- package/dist/auth/auth-resolver.js.map +1 -0
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +2 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/base-provider.d.ts +10 -0
- package/dist/base-provider.d.ts.map +1 -0
- package/dist/base-provider.js +8 -0
- package/dist/base-provider.js.map +1 -0
- package/dist/circuit-breaker.d.ts +42 -0
- package/dist/circuit-breaker.d.ts.map +1 -0
- package/dist/circuit-breaker.js +116 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/claude-provider.d.ts +15 -0
- package/dist/claude-provider.d.ts.map +1 -0
- package/dist/claude-provider.js +171 -0
- package/dist/claude-provider.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/openai-provider.d.ts +16 -0
- package/dist/openai-provider.d.ts.map +1 -0
- package/dist/openai-provider.js +196 -0
- package/dist/openai-provider.js.map +1 -0
- package/dist/provider-factory.d.ts +17 -0
- package/dist/provider-factory.d.ts.map +1 -0
- package/dist/provider-factory.js +36 -0
- package/dist/provider-factory.js.map +1 -0
- package/dist/retry-provider.d.ts +25 -0
- package/dist/retry-provider.d.ts.map +1 -0
- package/dist/retry-provider.js +92 -0
- package/dist/retry-provider.js.map +1 -0
- package/dist/thinking-parser.d.ts +28 -0
- package/dist/thinking-parser.d.ts.map +1 -0
- package/dist/thinking-parser.js +40 -0
- package/dist/thinking-parser.js.map +1 -0
- package/package.json +34 -0
- package/src/auth/auth-resolver.ts +261 -0
- package/src/auth/index.ts +1 -0
- package/src/base-provider.ts +28 -0
- package/src/circuit-breaker.ts +157 -0
- package/src/claude-provider.ts +215 -0
- package/src/index.ts +13 -0
- package/src/openai-provider.ts +239 -0
- package/src/provider-factory.ts +48 -0
- package/src/retry-provider.ts +135 -0
- package/src/thinking-parser.ts +50 -0
- package/tests/auth-resolver.test.ts +204 -0
- package/tests/circuit-breaker.test.ts +220 -0
- package/tests/claude-provider.test.ts +35 -0
- package/tests/openai-provider.test.ts +35 -0
- package/tests/provider-factory.test.ts +73 -0
- package/tests/retry-provider-new.test.ts +166 -0
- package/tests/retry-provider.test.ts +118 -0
- package/tests/thinking-parser.test.ts +73 -0
- package/tsconfig.json +10 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { AuthConfig, ResolvedCredential } from '@charming_groot/core';
|
|
2
|
+
import { ProviderError } from '@charming_groot/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Synchronously extract a token/apiKey from an AuthConfig.
|
|
6
|
+
* For auth types that require async token exchange (OAuth client_credentials, Azure AD),
|
|
7
|
+
* the caller should use resolveAuth() first and pass the accessToken.
|
|
8
|
+
*/
|
|
9
|
+
export function extractToken(auth: AuthConfig): string {
|
|
10
|
+
switch (auth.type) {
|
|
11
|
+
case 'no-auth':
|
|
12
|
+
return 'no-auth';
|
|
13
|
+
case 'api-key':
|
|
14
|
+
return auth.apiKey;
|
|
15
|
+
case 'oauth':
|
|
16
|
+
return auth.accessToken ?? '';
|
|
17
|
+
case 'azure-ad':
|
|
18
|
+
return auth.accessToken ?? '';
|
|
19
|
+
case 'aws-iam':
|
|
20
|
+
return auth.accessKeyId ?? '';
|
|
21
|
+
case 'gcp-service-account':
|
|
22
|
+
return auth.accessToken ?? '';
|
|
23
|
+
case 'credential-file':
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function resolveAuth(auth: AuthConfig): Promise<ResolvedCredential> {
|
|
29
|
+
switch (auth.type) {
|
|
30
|
+
case 'no-auth':
|
|
31
|
+
return { type: 'no-auth', headers: {}, token: undefined };
|
|
32
|
+
case 'api-key':
|
|
33
|
+
return resolveApiKey(auth);
|
|
34
|
+
case 'oauth':
|
|
35
|
+
return resolveOAuth(auth);
|
|
36
|
+
case 'azure-ad':
|
|
37
|
+
return resolveAzureAd(auth);
|
|
38
|
+
case 'aws-iam':
|
|
39
|
+
return resolveAwsIam(auth);
|
|
40
|
+
case 'gcp-service-account':
|
|
41
|
+
return resolveGcp(auth);
|
|
42
|
+
case 'credential-file':
|
|
43
|
+
return resolveCredentialFile(auth);
|
|
44
|
+
default:
|
|
45
|
+
throw new ProviderError(`Unsupported auth type: ${(auth as AuthConfig).type}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveApiKey(auth: { type: 'api-key'; apiKey: string }): ResolvedCredential {
|
|
50
|
+
return {
|
|
51
|
+
type: 'api-key',
|
|
52
|
+
headers: { Authorization: `Bearer ${auth.apiKey}` },
|
|
53
|
+
token: auth.apiKey,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function resolveOAuth(auth: {
|
|
58
|
+
type: 'oauth';
|
|
59
|
+
clientId: string;
|
|
60
|
+
clientSecret: string;
|
|
61
|
+
tokenUrl: string;
|
|
62
|
+
scopes?: readonly string[];
|
|
63
|
+
accessToken?: string;
|
|
64
|
+
refreshToken?: string;
|
|
65
|
+
}): Promise<ResolvedCredential> {
|
|
66
|
+
// If we already have a valid access token, use it
|
|
67
|
+
if (auth.accessToken) {
|
|
68
|
+
return {
|
|
69
|
+
type: 'oauth',
|
|
70
|
+
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
|
71
|
+
token: auth.accessToken,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Client credentials flow
|
|
76
|
+
const params = new URLSearchParams({
|
|
77
|
+
grant_type: auth.refreshToken ? 'refresh_token' : 'client_credentials',
|
|
78
|
+
client_id: auth.clientId,
|
|
79
|
+
client_secret: auth.clientSecret,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (auth.refreshToken) {
|
|
83
|
+
params.set('refresh_token', auth.refreshToken);
|
|
84
|
+
}
|
|
85
|
+
if (auth.scopes && auth.scopes.length > 0) {
|
|
86
|
+
params.set('scope', auth.scopes.join(' '));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const response = await fetch(auth.tokenUrl, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
92
|
+
body: params.toString(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const body = await response.text();
|
|
97
|
+
throw new ProviderError(`OAuth token request failed (${response.status}): ${body}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const data = await response.json() as { access_token: string; expires_in?: number };
|
|
101
|
+
const expiresAt = data.expires_in
|
|
102
|
+
? new Date(Date.now() + data.expires_in * 1000)
|
|
103
|
+
: undefined;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
type: 'oauth',
|
|
107
|
+
headers: { Authorization: `Bearer ${data.access_token}` },
|
|
108
|
+
token: data.access_token,
|
|
109
|
+
expiresAt,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function resolveAzureAd(auth: {
|
|
114
|
+
type: 'azure-ad';
|
|
115
|
+
tenantId: string;
|
|
116
|
+
clientId: string;
|
|
117
|
+
clientSecret?: string;
|
|
118
|
+
accessToken?: string;
|
|
119
|
+
}): Promise<ResolvedCredential> {
|
|
120
|
+
if (auth.accessToken) {
|
|
121
|
+
return {
|
|
122
|
+
type: 'azure-ad',
|
|
123
|
+
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
|
124
|
+
token: auth.accessToken,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!auth.clientSecret) {
|
|
129
|
+
throw new ProviderError('Azure AD auth requires either accessToken or clientSecret');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const tokenUrl = `https://login.microsoftonline.com/${auth.tenantId}/oauth2/v2.0/token`;
|
|
133
|
+
const params = new URLSearchParams({
|
|
134
|
+
grant_type: 'client_credentials',
|
|
135
|
+
client_id: auth.clientId,
|
|
136
|
+
client_secret: auth.clientSecret,
|
|
137
|
+
scope: 'https://cognitiveservices.azure.com/.default',
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const response = await fetch(tokenUrl, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
143
|
+
body: params.toString(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
const body = await response.text();
|
|
148
|
+
throw new ProviderError(`Azure AD token request failed (${response.status}): ${body}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const data = await response.json() as { access_token: string; expires_in?: number };
|
|
152
|
+
const expiresAt = data.expires_in
|
|
153
|
+
? new Date(Date.now() + data.expires_in * 1000)
|
|
154
|
+
: undefined;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
type: 'azure-ad',
|
|
158
|
+
headers: {
|
|
159
|
+
Authorization: `Bearer ${data.access_token}`,
|
|
160
|
+
'api-key': data.access_token,
|
|
161
|
+
},
|
|
162
|
+
token: data.access_token,
|
|
163
|
+
expiresAt,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function resolveAwsIam(auth: {
|
|
168
|
+
type: 'aws-iam';
|
|
169
|
+
accessKeyId?: string;
|
|
170
|
+
secretAccessKey?: string;
|
|
171
|
+
sessionToken?: string;
|
|
172
|
+
region: string;
|
|
173
|
+
profile?: string;
|
|
174
|
+
}): Promise<ResolvedCredential> {
|
|
175
|
+
// If explicit credentials provided, use them directly
|
|
176
|
+
// Real AWS Bedrock signing would use AWS SDK's SigV4
|
|
177
|
+
// This provides the credential structure for consumers to use
|
|
178
|
+
const headers: Record<string, string> = {};
|
|
179
|
+
|
|
180
|
+
if (auth.accessKeyId && auth.secretAccessKey) {
|
|
181
|
+
headers['x-aws-access-key-id'] = auth.accessKeyId;
|
|
182
|
+
headers['x-aws-region'] = auth.region;
|
|
183
|
+
if (auth.sessionToken) {
|
|
184
|
+
headers['x-aws-session-token'] = auth.sessionToken;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
type: 'aws-iam',
|
|
190
|
+
headers,
|
|
191
|
+
token: auth.accessKeyId,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function resolveGcp(auth: {
|
|
196
|
+
type: 'gcp-service-account';
|
|
197
|
+
projectId: string;
|
|
198
|
+
keyFilePath?: string;
|
|
199
|
+
accessToken?: string;
|
|
200
|
+
}): Promise<ResolvedCredential> {
|
|
201
|
+
if (auth.accessToken) {
|
|
202
|
+
return {
|
|
203
|
+
type: 'gcp-service-account',
|
|
204
|
+
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
|
205
|
+
token: auth.accessToken,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// When keyFilePath is provided, consumers should use Google Auth Library
|
|
210
|
+
// This returns a placeholder that signals ADC should be used
|
|
211
|
+
return {
|
|
212
|
+
type: 'gcp-service-account',
|
|
213
|
+
headers: {},
|
|
214
|
+
token: undefined,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function resolveCredentialFile(auth: {
|
|
219
|
+
type: 'credential-file';
|
|
220
|
+
filePath: string;
|
|
221
|
+
profile?: string;
|
|
222
|
+
}): Promise<ResolvedCredential> {
|
|
223
|
+
const { readFile } = await import('node:fs/promises');
|
|
224
|
+
let content: string;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
content = await readFile(auth.filePath, 'utf-8');
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw new ProviderError(
|
|
230
|
+
`Failed to read credential file: ${auth.filePath}`,
|
|
231
|
+
error instanceof Error ? error : undefined
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const credentials = JSON.parse(content) as Record<string, Record<string, string>>;
|
|
237
|
+
const profile = auth.profile ?? 'default';
|
|
238
|
+
const entry = credentials[profile];
|
|
239
|
+
|
|
240
|
+
if (!entry) {
|
|
241
|
+
throw new ProviderError(`Profile '${profile}' not found in credential file`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const apiKey = entry['api_key'] ?? entry['apiKey'] ?? entry['token'];
|
|
245
|
+
if (!apiKey) {
|
|
246
|
+
throw new ProviderError(`No api_key/apiKey/token found in profile '${profile}'`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
type: 'credential-file',
|
|
251
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
252
|
+
token: apiKey,
|
|
253
|
+
};
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error instanceof ProviderError) throw error;
|
|
256
|
+
throw new ProviderError(
|
|
257
|
+
`Failed to parse credential file: ${auth.filePath}`,
|
|
258
|
+
error instanceof Error ? error : undefined
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { resolveAuth, extractToken } from './auth-resolver.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ILlmProvider,
|
|
3
|
+
Message,
|
|
4
|
+
LlmResponse,
|
|
5
|
+
StreamEvent,
|
|
6
|
+
ToolDescription,
|
|
7
|
+
} from '@charming_groot/core';
|
|
8
|
+
import { createChildLogger } from '@charming_groot/core';
|
|
9
|
+
import type { AgentLogger } from '@charming_groot/core';
|
|
10
|
+
|
|
11
|
+
export abstract class BaseProvider implements ILlmProvider {
|
|
12
|
+
abstract readonly providerId: string;
|
|
13
|
+
protected readonly logger: AgentLogger;
|
|
14
|
+
|
|
15
|
+
constructor(loggerName: string) {
|
|
16
|
+
this.logger = createChildLogger(loggerName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
abstract chat(
|
|
20
|
+
messages: readonly Message[],
|
|
21
|
+
tools?: readonly ToolDescription[]
|
|
22
|
+
): Promise<LlmResponse>;
|
|
23
|
+
|
|
24
|
+
abstract stream(
|
|
25
|
+
messages: readonly Message[],
|
|
26
|
+
tools?: readonly ToolDescription[]
|
|
27
|
+
): AsyncIterable<StreamEvent>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ILlmProvider,
|
|
3
|
+
Message,
|
|
4
|
+
LlmResponse,
|
|
5
|
+
StreamEvent,
|
|
6
|
+
ToolDescription,
|
|
7
|
+
AgentLogger,
|
|
8
|
+
} from '@charming_groot/core';
|
|
9
|
+
import { ProviderError, createChildLogger } from '@charming_groot/core';
|
|
10
|
+
|
|
11
|
+
export interface CircuitBreakerConfig {
|
|
12
|
+
/** Consecutive failures before opening the circuit (default: 5) */
|
|
13
|
+
failureThreshold?: number;
|
|
14
|
+
/** Consecutive successes in HALF_OPEN to close the circuit (default: 2) */
|
|
15
|
+
successThreshold?: number;
|
|
16
|
+
/** Ms to stay OPEN before allowing a probe request (default: 60_000) */
|
|
17
|
+
openTimeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type State = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Circuit breaker wrapping an ILlmProvider.
|
|
24
|
+
*
|
|
25
|
+
* CLOSED → normal operation, counts failures
|
|
26
|
+
* OPEN → rejects immediately, waits openTimeoutMs then probes
|
|
27
|
+
* HALF_OPEN → allows one request; success → CLOSED, failure → OPEN
|
|
28
|
+
*
|
|
29
|
+
* Wrap inside RetryProvider for best results:
|
|
30
|
+
* createProvider() → RetryProvider → CircuitBreakerProvider
|
|
31
|
+
*/
|
|
32
|
+
export class CircuitBreakerProvider implements ILlmProvider {
|
|
33
|
+
readonly providerId: string;
|
|
34
|
+
|
|
35
|
+
private readonly inner: ILlmProvider;
|
|
36
|
+
private readonly failureThreshold: number;
|
|
37
|
+
private readonly successThreshold: number;
|
|
38
|
+
private readonly openTimeoutMs: number;
|
|
39
|
+
private readonly logger: AgentLogger;
|
|
40
|
+
|
|
41
|
+
private state: State = 'CLOSED';
|
|
42
|
+
private failureCount = 0;
|
|
43
|
+
private successCount = 0;
|
|
44
|
+
private openedAt = 0;
|
|
45
|
+
|
|
46
|
+
constructor(provider: ILlmProvider, config?: CircuitBreakerConfig) {
|
|
47
|
+
this.inner = provider;
|
|
48
|
+
this.providerId = provider.providerId;
|
|
49
|
+
this.failureThreshold = config?.failureThreshold ?? 5;
|
|
50
|
+
this.successThreshold = config?.successThreshold ?? 2;
|
|
51
|
+
this.openTimeoutMs = config?.openTimeoutMs ?? 60_000;
|
|
52
|
+
this.logger = createChildLogger('circuit-breaker');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get currentState(): State {
|
|
56
|
+
return this.state;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async chat(
|
|
60
|
+
messages: readonly Message[],
|
|
61
|
+
tools?: readonly ToolDescription[]
|
|
62
|
+
): Promise<LlmResponse> {
|
|
63
|
+
this.guardOrThrow();
|
|
64
|
+
try {
|
|
65
|
+
const result = await this.inner.chat(messages, tools);
|
|
66
|
+
this.onSuccess();
|
|
67
|
+
return result;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
this.onFailure(error);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async *stream(
|
|
75
|
+
messages: readonly Message[],
|
|
76
|
+
tools?: readonly ToolDescription[]
|
|
77
|
+
): AsyncIterable<StreamEvent> {
|
|
78
|
+
this.guardOrThrow();
|
|
79
|
+
try {
|
|
80
|
+
yield* this.inner.stream(messages, tools);
|
|
81
|
+
this.onSuccess();
|
|
82
|
+
} catch (error) {
|
|
83
|
+
this.onFailure(error);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Internal state machine ───────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
private guardOrThrow(): void {
|
|
91
|
+
if (this.state === 'CLOSED' || this.state === 'HALF_OPEN') return;
|
|
92
|
+
|
|
93
|
+
// OPEN: check if timeout has elapsed
|
|
94
|
+
const elapsed = Date.now() - this.openedAt;
|
|
95
|
+
if (elapsed >= this.openTimeoutMs) {
|
|
96
|
+
this.transitionTo('HALF_OPEN');
|
|
97
|
+
return; // allow this probe request through
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const remaining = Math.ceil((this.openTimeoutMs - elapsed) / 1000);
|
|
101
|
+
throw new ProviderError(
|
|
102
|
+
`Circuit breaker OPEN for provider '${this.providerId}' — retry in ${remaining}s`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private onSuccess(): void {
|
|
107
|
+
if (this.state === 'HALF_OPEN') {
|
|
108
|
+
this.successCount++;
|
|
109
|
+
if (this.successCount >= this.successThreshold) {
|
|
110
|
+
this.transitionTo('CLOSED');
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
this.failureCount = 0; // reset on any success in CLOSED
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private onFailure(error: unknown): void {
|
|
118
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
119
|
+
this.logger.warn({ state: this.state, error: msg }, 'Circuit breaker recorded failure');
|
|
120
|
+
|
|
121
|
+
if (this.state === 'HALF_OPEN') {
|
|
122
|
+
// Probe failed → back to OPEN
|
|
123
|
+
this.transitionTo('OPEN');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.failureCount++;
|
|
128
|
+
if (this.failureCount >= this.failureThreshold) {
|
|
129
|
+
this.transitionTo('OPEN');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private transitionTo(next: State): void {
|
|
134
|
+
const prev = this.state;
|
|
135
|
+
this.state = next;
|
|
136
|
+
|
|
137
|
+
if (next === 'OPEN') {
|
|
138
|
+
this.openedAt = Date.now();
|
|
139
|
+
this.successCount = 0;
|
|
140
|
+
this.logger.error(
|
|
141
|
+
{ failureCount: this.failureCount, openTimeoutMs: this.openTimeoutMs },
|
|
142
|
+
`Circuit breaker OPENED for provider '${this.providerId}'`
|
|
143
|
+
);
|
|
144
|
+
} else if (next === 'HALF_OPEN') {
|
|
145
|
+
this.successCount = 0;
|
|
146
|
+
this.logger.warn({}, `Circuit breaker HALF_OPEN — probing provider '${this.providerId}'`);
|
|
147
|
+
} else {
|
|
148
|
+
this.failureCount = 0;
|
|
149
|
+
this.logger.info({}, `Circuit breaker CLOSED for provider '${this.providerId}'`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (prev !== next) {
|
|
153
|
+
// Allow external inspection of state transitions
|
|
154
|
+
this.logger.debug({ from: prev, to: next }, 'Circuit breaker state transition');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import type {
|
|
3
|
+
Message,
|
|
4
|
+
LlmResponse,
|
|
5
|
+
StreamEvent,
|
|
6
|
+
ToolDescription,
|
|
7
|
+
ToolCall,
|
|
8
|
+
ProviderConfig,
|
|
9
|
+
} from '@charming_groot/core';
|
|
10
|
+
import { ProviderError } from '@charming_groot/core';
|
|
11
|
+
import { BaseProvider } from './base-provider.js';
|
|
12
|
+
import { extractToken } from './auth/auth-resolver.js';
|
|
13
|
+
import { extractThinkTag, estimateThinkingMs } from './thinking-parser.js';
|
|
14
|
+
|
|
15
|
+
interface AnthropicToolParam {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
input_schema: {
|
|
19
|
+
type: 'object';
|
|
20
|
+
properties: Record<string, { type: string; description: string }>;
|
|
21
|
+
required: string[];
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ClaudeProvider extends BaseProvider {
|
|
26
|
+
readonly providerId = 'claude';
|
|
27
|
+
private readonly client: Anthropic;
|
|
28
|
+
private readonly model: string;
|
|
29
|
+
private readonly maxTokens: number;
|
|
30
|
+
|
|
31
|
+
constructor(config: ProviderConfig) {
|
|
32
|
+
super('claude-provider');
|
|
33
|
+
const apiKey = extractToken(config.auth);
|
|
34
|
+
this.client = new Anthropic({ apiKey, baseURL: config.baseUrl });
|
|
35
|
+
this.model = config.model;
|
|
36
|
+
this.maxTokens = config.maxTokens;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async chat(
|
|
40
|
+
messages: readonly Message[],
|
|
41
|
+
tools?: readonly ToolDescription[]
|
|
42
|
+
): Promise<LlmResponse> {
|
|
43
|
+
try {
|
|
44
|
+
const systemMsg = messages.find((m) => m.role === 'system');
|
|
45
|
+
const nonSystemMsgs = messages.filter((m) => m.role !== 'system');
|
|
46
|
+
|
|
47
|
+
const response = await this.client.messages.create({
|
|
48
|
+
model: this.model,
|
|
49
|
+
max_tokens: this.maxTokens,
|
|
50
|
+
system: systemMsg?.content,
|
|
51
|
+
messages: this.toAnthropicMessages(nonSystemMsgs),
|
|
52
|
+
tools: tools ? this.toAnthropicTools(tools) : undefined,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return this.parseResponse(response);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new ProviderError(
|
|
58
|
+
`Claude API error: ${error instanceof Error ? error.message : String(error)}`,
|
|
59
|
+
error instanceof Error ? error : undefined
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async *stream(
|
|
65
|
+
messages: readonly Message[],
|
|
66
|
+
tools?: readonly ToolDescription[]
|
|
67
|
+
): AsyncIterable<StreamEvent> {
|
|
68
|
+
try {
|
|
69
|
+
const systemMsg = messages.find((m) => m.role === 'system');
|
|
70
|
+
const nonSystemMsgs = messages.filter((m) => m.role !== 'system');
|
|
71
|
+
|
|
72
|
+
const stream = this.client.messages.stream({
|
|
73
|
+
model: this.model,
|
|
74
|
+
max_tokens: this.maxTokens,
|
|
75
|
+
system: systemMsg?.content,
|
|
76
|
+
messages: this.toAnthropicMessages(nonSystemMsgs),
|
|
77
|
+
tools: tools ? this.toAnthropicTools(tools) : undefined,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
for await (const event of stream) {
|
|
81
|
+
if (event.type === 'content_block_delta') {
|
|
82
|
+
const delta = event.delta as { type: string; text?: string; partial_json?: string };
|
|
83
|
+
if (delta.type === 'text_delta' && delta.text) {
|
|
84
|
+
yield { type: 'text_delta', content: delta.text };
|
|
85
|
+
} else if (delta.type === 'input_json_delta' && delta.partial_json) {
|
|
86
|
+
yield { type: 'tool_call_delta', content: delta.partial_json };
|
|
87
|
+
}
|
|
88
|
+
} else if (event.type === 'content_block_start') {
|
|
89
|
+
const block = event.content_block as { type: string; id?: string; name?: string };
|
|
90
|
+
if (block.type === 'tool_use') {
|
|
91
|
+
yield {
|
|
92
|
+
type: 'tool_call_start',
|
|
93
|
+
toolCall: { id: block.id, name: block.name },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
} else if (event.type === 'message_stop') {
|
|
97
|
+
const finalMessage = await stream.finalMessage();
|
|
98
|
+
yield { type: 'done', response: this.parseResponse(finalMessage) };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new ProviderError(
|
|
103
|
+
`Claude stream error: ${error instanceof Error ? error.message : String(error)}`,
|
|
104
|
+
error instanceof Error ? error : undefined
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private toAnthropicMessages(
|
|
110
|
+
messages: readonly Message[]
|
|
111
|
+
): Anthropic.MessageParam[] {
|
|
112
|
+
return messages.map((msg) => {
|
|
113
|
+
if (msg.toolResults && msg.toolResults.length > 0) {
|
|
114
|
+
return {
|
|
115
|
+
role: 'user' as const,
|
|
116
|
+
content: msg.toolResults.map((tr) => ({
|
|
117
|
+
type: 'tool_result' as const,
|
|
118
|
+
tool_use_id: tr.toolCallId,
|
|
119
|
+
content: tr.content,
|
|
120
|
+
})),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
125
|
+
const content: Anthropic.ContentBlockParam[] = [];
|
|
126
|
+
if (msg.content) {
|
|
127
|
+
content.push({ type: 'text', text: msg.content });
|
|
128
|
+
}
|
|
129
|
+
for (const tc of msg.toolCalls) {
|
|
130
|
+
content.push({
|
|
131
|
+
type: 'tool_use',
|
|
132
|
+
id: tc.id,
|
|
133
|
+
name: tc.name,
|
|
134
|
+
input: JSON.parse(tc.arguments),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return { role: 'assistant' as const, content };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
role: msg.role as 'user' | 'assistant',
|
|
142
|
+
content: msg.content,
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private toAnthropicTools(tools: readonly ToolDescription[]): AnthropicToolParam[] {
|
|
148
|
+
return tools.map((tool) => ({
|
|
149
|
+
name: tool.name,
|
|
150
|
+
description: tool.description,
|
|
151
|
+
input_schema: {
|
|
152
|
+
type: 'object' as const,
|
|
153
|
+
properties: Object.fromEntries(
|
|
154
|
+
tool.parameters.map((p) => [
|
|
155
|
+
p.name,
|
|
156
|
+
{ type: p.type, description: p.description },
|
|
157
|
+
])
|
|
158
|
+
),
|
|
159
|
+
required: tool.parameters
|
|
160
|
+
.filter((p) => p.required)
|
|
161
|
+
.map((p) => p.name),
|
|
162
|
+
},
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private parseResponse(response: Anthropic.Message): LlmResponse {
|
|
167
|
+
let content = '';
|
|
168
|
+
const toolCalls: ToolCall[] = [];
|
|
169
|
+
let thinkingMs: number | undefined;
|
|
170
|
+
|
|
171
|
+
for (const block of response.content) {
|
|
172
|
+
if (block.type === 'thinking') {
|
|
173
|
+
// Anthropic extended thinking block — estimate duration from token count
|
|
174
|
+
// (no direct timing from API, but the block existing means thinking occurred)
|
|
175
|
+
const thinkingBlock = block as { type: 'thinking'; thinking: string };
|
|
176
|
+
thinkingMs = estimateThinkingMs(thinkingBlock.thinking);
|
|
177
|
+
} else if (block.type === 'text') {
|
|
178
|
+
content += block.text;
|
|
179
|
+
} else if (block.type === 'tool_use') {
|
|
180
|
+
toolCalls.push({
|
|
181
|
+
id: block.id,
|
|
182
|
+
name: block.name,
|
|
183
|
+
arguments: JSON.stringify(block.input),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Fallback: parse <think>...</think> tags from text content (DeepSeek, etc.)
|
|
189
|
+
const parsed = extractThinkTag(content);
|
|
190
|
+
if (parsed.thinkContent) {
|
|
191
|
+
content = parsed.cleanContent;
|
|
192
|
+
if (!thinkingMs) {
|
|
193
|
+
thinkingMs = estimateThinkingMs(parsed.thinkContent);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const stopReason =
|
|
198
|
+
response.stop_reason === 'tool_use'
|
|
199
|
+
? 'tool_use' as const
|
|
200
|
+
: response.stop_reason === 'max_tokens'
|
|
201
|
+
? 'max_tokens' as const
|
|
202
|
+
: 'end_turn' as const;
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
content,
|
|
206
|
+
stopReason,
|
|
207
|
+
toolCalls,
|
|
208
|
+
usage: {
|
|
209
|
+
inputTokens: response.usage.input_tokens,
|
|
210
|
+
outputTokens: response.usage.output_tokens,
|
|
211
|
+
thinkingMs,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { BaseProvider } from './base-provider.js';
|
|
2
|
+
export { ClaudeProvider } from './claude-provider.js';
|
|
3
|
+
export { OpenAIProvider } from './openai-provider.js';
|
|
4
|
+
export {
|
|
5
|
+
createProvider,
|
|
6
|
+
registerProvider,
|
|
7
|
+
getProviderRegistry,
|
|
8
|
+
} from './provider-factory.js';
|
|
9
|
+
export { RetryProvider, type RetryConfig } from './retry-provider.js';
|
|
10
|
+
export { CircuitBreakerProvider, type CircuitBreakerConfig } from './circuit-breaker.js';
|
|
11
|
+
export { resolveAuth, extractToken } from './auth/index.js';
|
|
12
|
+
export { extractThinkTag, estimateThinkingMs } from './thinking-parser.js';
|
|
13
|
+
export type { ThinkTagResult } from './thinking-parser.js';
|