@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.
Files changed (61) hide show
  1. package/dist/auth/auth-resolver.d.ts +9 -0
  2. package/dist/auth/auth-resolver.d.ts.map +1 -0
  3. package/dist/auth/auth-resolver.js +200 -0
  4. package/dist/auth/auth-resolver.js.map +1 -0
  5. package/dist/auth/index.d.ts +2 -0
  6. package/dist/auth/index.d.ts.map +1 -0
  7. package/dist/auth/index.js +2 -0
  8. package/dist/auth/index.js.map +1 -0
  9. package/dist/base-provider.d.ts +10 -0
  10. package/dist/base-provider.d.ts.map +1 -0
  11. package/dist/base-provider.js +8 -0
  12. package/dist/base-provider.js.map +1 -0
  13. package/dist/circuit-breaker.d.ts +42 -0
  14. package/dist/circuit-breaker.d.ts.map +1 -0
  15. package/dist/circuit-breaker.js +116 -0
  16. package/dist/circuit-breaker.js.map +1 -0
  17. package/dist/claude-provider.d.ts +15 -0
  18. package/dist/claude-provider.d.ts.map +1 -0
  19. package/dist/claude-provider.js +171 -0
  20. package/dist/claude-provider.js.map +1 -0
  21. package/dist/index.d.ts +10 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +9 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/openai-provider.d.ts +16 -0
  26. package/dist/openai-provider.d.ts.map +1 -0
  27. package/dist/openai-provider.js +196 -0
  28. package/dist/openai-provider.js.map +1 -0
  29. package/dist/provider-factory.d.ts +17 -0
  30. package/dist/provider-factory.d.ts.map +1 -0
  31. package/dist/provider-factory.js +36 -0
  32. package/dist/provider-factory.js.map +1 -0
  33. package/dist/retry-provider.d.ts +25 -0
  34. package/dist/retry-provider.d.ts.map +1 -0
  35. package/dist/retry-provider.js +92 -0
  36. package/dist/retry-provider.js.map +1 -0
  37. package/dist/thinking-parser.d.ts +28 -0
  38. package/dist/thinking-parser.d.ts.map +1 -0
  39. package/dist/thinking-parser.js +40 -0
  40. package/dist/thinking-parser.js.map +1 -0
  41. package/package.json +34 -0
  42. package/src/auth/auth-resolver.ts +261 -0
  43. package/src/auth/index.ts +1 -0
  44. package/src/base-provider.ts +28 -0
  45. package/src/circuit-breaker.ts +157 -0
  46. package/src/claude-provider.ts +215 -0
  47. package/src/index.ts +13 -0
  48. package/src/openai-provider.ts +239 -0
  49. package/src/provider-factory.ts +48 -0
  50. package/src/retry-provider.ts +135 -0
  51. package/src/thinking-parser.ts +50 -0
  52. package/tests/auth-resolver.test.ts +204 -0
  53. package/tests/circuit-breaker.test.ts +220 -0
  54. package/tests/claude-provider.test.ts +35 -0
  55. package/tests/openai-provider.test.ts +35 -0
  56. package/tests/provider-factory.test.ts +73 -0
  57. package/tests/retry-provider-new.test.ts +166 -0
  58. package/tests/retry-provider.test.ts +118 -0
  59. package/tests/thinking-parser.test.ts +73 -0
  60. package/tsconfig.json +10 -0
  61. package/vitest.config.ts +15 -0
@@ -0,0 +1,239 @@
1
+ import OpenAI from 'openai';
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
+ export class OpenAIProvider extends BaseProvider {
16
+ readonly providerId = 'openai';
17
+ private readonly client: OpenAI;
18
+ private readonly model: string;
19
+ private readonly maxTokens: number;
20
+ private readonly temperature: number;
21
+
22
+ constructor(config: ProviderConfig) {
23
+ super('openai-provider');
24
+ const apiKey = extractToken(config.auth);
25
+ this.client = new OpenAI({ apiKey, baseURL: config.baseUrl });
26
+ this.model = config.model;
27
+ this.maxTokens = config.maxTokens;
28
+ this.temperature = config.temperature;
29
+ }
30
+
31
+ async chat(
32
+ messages: readonly Message[],
33
+ tools?: readonly ToolDescription[]
34
+ ): Promise<LlmResponse> {
35
+ try {
36
+ const response = await this.client.chat.completions.create({
37
+ model: this.model,
38
+ max_tokens: this.maxTokens,
39
+ temperature: this.temperature,
40
+ messages: this.toOpenAIMessages(messages),
41
+ tools: tools && tools.length > 0 ? this.toOpenAITools(tools) : undefined,
42
+ });
43
+
44
+ return this.parseResponse(response);
45
+ } catch (error) {
46
+ throw new ProviderError(
47
+ `OpenAI API error: ${error instanceof Error ? error.message : String(error)}`,
48
+ error instanceof Error ? error : undefined
49
+ );
50
+ }
51
+ }
52
+
53
+ async *stream(
54
+ messages: readonly Message[],
55
+ tools?: readonly ToolDescription[]
56
+ ): AsyncIterable<StreamEvent> {
57
+ try {
58
+ const stream = await this.client.chat.completions.create({
59
+ model: this.model,
60
+ max_tokens: this.maxTokens,
61
+ temperature: this.temperature,
62
+ messages: this.toOpenAIMessages(messages),
63
+ tools: tools && tools.length > 0 ? this.toOpenAITools(tools) : undefined,
64
+ stream: true,
65
+ });
66
+
67
+ let content = '';
68
+ const toolCalls: ToolCall[] = [];
69
+ let inputTokens = 0;
70
+ let outputTokens = 0;
71
+
72
+ for await (const chunk of stream) {
73
+ const delta = chunk.choices[0]?.delta;
74
+ if (!delta) continue;
75
+
76
+ if (delta.content) {
77
+ content += delta.content;
78
+ yield { type: 'text_delta', content: delta.content };
79
+ }
80
+
81
+ if (delta.tool_calls) {
82
+ for (const tc of delta.tool_calls) {
83
+ if (tc.id) {
84
+ toolCalls.push({
85
+ id: tc.id,
86
+ name: tc.function?.name ?? '',
87
+ arguments: tc.function?.arguments ?? '',
88
+ });
89
+ yield {
90
+ type: 'tool_call_start',
91
+ toolCall: { id: tc.id, name: tc.function?.name },
92
+ };
93
+ } else if (tc.function?.arguments) {
94
+ const last = toolCalls[toolCalls.length - 1];
95
+ if (last) {
96
+ toolCalls[toolCalls.length - 1] = {
97
+ ...last,
98
+ arguments: last.arguments + tc.function.arguments,
99
+ };
100
+ }
101
+ yield { type: 'tool_call_delta', content: tc.function.arguments };
102
+ }
103
+ }
104
+ }
105
+
106
+ if (chunk.usage) {
107
+ inputTokens = chunk.usage.prompt_tokens;
108
+ outputTokens = chunk.usage.completion_tokens;
109
+ }
110
+
111
+ if (chunk.choices[0]?.finish_reason) {
112
+ const stopReason =
113
+ chunk.choices[0].finish_reason === 'tool_calls'
114
+ ? 'tool_use' as const
115
+ : chunk.choices[0].finish_reason === 'length'
116
+ ? 'max_tokens' as const
117
+ : 'end_turn' as const;
118
+
119
+ yield {
120
+ type: 'done',
121
+ response: {
122
+ content,
123
+ stopReason,
124
+ toolCalls,
125
+ usage: { inputTokens, outputTokens },
126
+ },
127
+ };
128
+ }
129
+ }
130
+ } catch (error) {
131
+ throw new ProviderError(
132
+ `OpenAI stream error: ${error instanceof Error ? error.message : String(error)}`,
133
+ error instanceof Error ? error : undefined
134
+ );
135
+ }
136
+ }
137
+
138
+ private toOpenAIMessages(
139
+ messages: readonly Message[]
140
+ ): OpenAI.ChatCompletionMessageParam[] {
141
+ const result: OpenAI.ChatCompletionMessageParam[] = [];
142
+
143
+ for (const msg of messages) {
144
+ if (msg.role === 'system') {
145
+ result.push({ role: 'system', content: msg.content });
146
+ } else if (msg.toolResults && msg.toolResults.length > 0) {
147
+ for (const tr of msg.toolResults) {
148
+ result.push({
149
+ role: 'tool',
150
+ tool_call_id: tr.toolCallId,
151
+ content: tr.content,
152
+ });
153
+ }
154
+ } else if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
155
+ result.push({
156
+ role: 'assistant',
157
+ content: msg.content || null,
158
+ tool_calls: msg.toolCalls.map((tc) => ({
159
+ id: tc.id,
160
+ type: 'function' as const,
161
+ function: { name: tc.name, arguments: tc.arguments },
162
+ })),
163
+ });
164
+ } else if (msg.role === 'assistant') {
165
+ result.push({ role: 'assistant', content: msg.content });
166
+ } else {
167
+ result.push({ role: 'user', content: msg.content });
168
+ }
169
+ }
170
+
171
+ return result;
172
+ }
173
+
174
+ private toOpenAITools(
175
+ tools: readonly ToolDescription[]
176
+ ): OpenAI.ChatCompletionTool[] {
177
+ return tools.map((tool) => ({
178
+ type: 'function' as const,
179
+ function: {
180
+ name: tool.name,
181
+ description: tool.description,
182
+ parameters: {
183
+ type: 'object',
184
+ properties: Object.fromEntries(
185
+ tool.parameters.map((p) => [
186
+ p.name,
187
+ { type: p.type, description: p.description },
188
+ ])
189
+ ),
190
+ required: tool.parameters
191
+ .filter((p) => p.required)
192
+ .map((p) => p.name),
193
+ },
194
+ },
195
+ }));
196
+ }
197
+
198
+ private parseResponse(
199
+ response: OpenAI.ChatCompletion
200
+ ): LlmResponse {
201
+ const choice = response.choices[0];
202
+ if (!choice) {
203
+ throw new ProviderError('No choices in OpenAI response');
204
+ }
205
+
206
+ const toolCalls: ToolCall[] = (choice.message.tool_calls ?? []).map((tc) => ({
207
+ id: tc.id,
208
+ name: tc.function.name,
209
+ arguments: tc.function.arguments,
210
+ }));
211
+
212
+ const stopReason =
213
+ choice.finish_reason === 'tool_calls'
214
+ ? 'tool_use' as const
215
+ : choice.finish_reason === 'length'
216
+ ? 'max_tokens' as const
217
+ : 'end_turn' as const;
218
+
219
+ let content = choice.message.content ?? '';
220
+ let thinkingMs: number | undefined;
221
+
222
+ const parsed = extractThinkTag(content);
223
+ if (parsed.thinkContent) {
224
+ content = parsed.cleanContent;
225
+ thinkingMs = estimateThinkingMs(parsed.thinkContent);
226
+ }
227
+
228
+ return {
229
+ content,
230
+ stopReason,
231
+ toolCalls,
232
+ usage: {
233
+ inputTokens: response.usage?.prompt_tokens ?? 0,
234
+ outputTokens: response.usage?.completion_tokens ?? 0,
235
+ thinkingMs,
236
+ },
237
+ };
238
+ }
239
+ }
@@ -0,0 +1,48 @@
1
+ import type { ILlmProvider, ProviderConfig } from '@charming_groot/core';
2
+ import { Registry, ProviderError } from '@charming_groot/core';
3
+ import { ClaudeProvider } from './claude-provider.js';
4
+ import { OpenAIProvider } from './openai-provider.js';
5
+ import { RetryProvider } from './retry-provider.js';
6
+ import { CircuitBreakerProvider } from './circuit-breaker.js';
7
+
8
+ type ProviderConstructor = new (config: ProviderConfig) => ILlmProvider;
9
+
10
+ const providerRegistry = new Registry<ProviderConstructor>('Provider');
11
+
12
+ providerRegistry.register('claude', ClaudeProvider);
13
+ providerRegistry.register('openai', OpenAIProvider);
14
+ providerRegistry.register('vllm', OpenAIProvider);
15
+ providerRegistry.register('ollama', OpenAIProvider);
16
+ providerRegistry.register('custom', OpenAIProvider);
17
+
18
+ /**
19
+ * Creates a provider wrapped with RetryProvider → CircuitBreakerProvider.
20
+ *
21
+ * Request flow:
22
+ * AgentLoop → CircuitBreakerProvider → RetryProvider → actual provider
23
+ *
24
+ * CircuitBreaker is outermost so it sees already-retried failures,
25
+ * preventing the circuit from tripping on transient single errors.
26
+ */
27
+ export function createProvider(config: ProviderConfig): ILlmProvider {
28
+ const Constructor = providerRegistry.tryGet(config.providerId);
29
+ if (!Constructor) {
30
+ throw new ProviderError(
31
+ `Unknown provider: '${config.providerId}'. Available: ${providerRegistry.getAllNames().join(', ')}`
32
+ );
33
+ }
34
+ const provider = new Constructor(config);
35
+ const withRetry = new RetryProvider(provider);
36
+ return new CircuitBreakerProvider(withRetry);
37
+ }
38
+
39
+ export function registerProvider(
40
+ id: string,
41
+ constructor: ProviderConstructor
42
+ ): void {
43
+ providerRegistry.register(id, constructor);
44
+ }
45
+
46
+ export function getProviderRegistry(): Registry<ProviderConstructor> {
47
+ return providerRegistry;
48
+ }
@@ -0,0 +1,135 @@
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
+ const DEFAULT_MAX_RETRIES = 3;
12
+ const DEFAULT_BASE_DELAY_MS = 1000;
13
+ const DEFAULT_MAX_DELAY_MS = 30000;
14
+
15
+ export interface RetryConfig {
16
+ /** Maximum number of retry attempts (default: 3) */
17
+ readonly maxRetries?: number;
18
+ /** Base delay in ms for exponential backoff (default: 1000) */
19
+ readonly baseDelayMs?: number;
20
+ /** Maximum delay in ms (default: 30000) */
21
+ readonly maxDelayMs?: number;
22
+ }
23
+
24
+ function isRetryable(error: unknown): boolean {
25
+ if (error instanceof ProviderError) {
26
+ const msg = error.message.toLowerCase();
27
+ return (
28
+ msg.includes('rate limit') ||
29
+ msg.includes('429') ||
30
+ msg.includes('too many requests') ||
31
+ msg.includes('overloaded') ||
32
+ msg.includes('529') ||
33
+ msg.includes('timeout') ||
34
+ msg.includes('econnreset') ||
35
+ msg.includes('socket hang up') ||
36
+ msg.includes('503') ||
37
+ msg.includes('500')
38
+ );
39
+ }
40
+
41
+ if (error instanceof Error) {
42
+ const msg = error.message.toLowerCase();
43
+ return (
44
+ msg.includes('econnreset') ||
45
+ msg.includes('etimedout') ||
46
+ msg.includes('socket hang up') ||
47
+ msg.includes('fetch failed')
48
+ );
49
+ }
50
+
51
+ return false;
52
+ }
53
+
54
+ function computeDelay(attempt: number, baseMs: number, maxMs: number): number {
55
+ const exponential = baseMs * Math.pow(2, attempt);
56
+ const jitter = Math.random() * baseMs;
57
+ return Math.min(exponential + jitter, maxMs);
58
+ }
59
+
60
+ /**
61
+ * Wraps an ILlmProvider with retry logic and exponential backoff.
62
+ * Retries on rate limits, transient network errors, and server errors.
63
+ */
64
+ export class RetryProvider implements ILlmProvider {
65
+ readonly providerId: string;
66
+ private readonly inner: ILlmProvider;
67
+ private readonly maxRetries: number;
68
+ private readonly baseDelayMs: number;
69
+ private readonly maxDelayMs: number;
70
+ private readonly logger: AgentLogger;
71
+
72
+ constructor(provider: ILlmProvider, config?: RetryConfig) {
73
+ this.inner = provider;
74
+ this.providerId = provider.providerId;
75
+ this.maxRetries = config?.maxRetries ?? DEFAULT_MAX_RETRIES;
76
+ this.baseDelayMs = config?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
77
+ this.maxDelayMs = config?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
78
+ this.logger = createChildLogger('retry-provider');
79
+ }
80
+
81
+ async chat(
82
+ messages: readonly Message[],
83
+ tools?: readonly ToolDescription[]
84
+ ): Promise<LlmResponse> {
85
+ let lastError: unknown;
86
+
87
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
88
+ try {
89
+ return await this.inner.chat(messages, tools);
90
+ } catch (error) {
91
+ lastError = error;
92
+ if (attempt < this.maxRetries && isRetryable(error)) {
93
+ const delay = computeDelay(attempt, this.baseDelayMs, this.maxDelayMs);
94
+ this.logger.warn(
95
+ { attempt: attempt + 1, maxRetries: this.maxRetries, delayMs: Math.round(delay) },
96
+ 'Retrying after transient error'
97
+ );
98
+ await new Promise(resolve => setTimeout(resolve, delay));
99
+ continue;
100
+ }
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ throw lastError;
106
+ }
107
+
108
+ async *stream(
109
+ messages: readonly Message[],
110
+ tools?: readonly ToolDescription[]
111
+ ): AsyncIterable<StreamEvent> {
112
+ let lastError: unknown;
113
+
114
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
115
+ try {
116
+ yield* this.inner.stream(messages, tools);
117
+ return;
118
+ } catch (error) {
119
+ lastError = error;
120
+ if (attempt < this.maxRetries && isRetryable(error)) {
121
+ const delay = computeDelay(attempt, this.baseDelayMs, this.maxDelayMs);
122
+ this.logger.warn(
123
+ { attempt: attempt + 1, maxRetries: this.maxRetries, delayMs: Math.round(delay) },
124
+ 'Retrying stream after transient error'
125
+ );
126
+ await new Promise(resolve => setTimeout(resolve, delay));
127
+ continue;
128
+ }
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ throw lastError;
134
+ }
135
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Parsing utilities for extended thinking / <think> blocks.
3
+ *
4
+ * Supports:
5
+ * - <think>...</think> tags (DeepSeek, Qwen, etc.)
6
+ * - Anthropic extended thinking content blocks (handled in provider)
7
+ *
8
+ * The thinkingMs is estimated from content length since the API
9
+ * does not provide wall-clock timing for the thinking phase.
10
+ */
11
+
12
+ const THINK_TAG_REGEX = /^<think>([\s\S]*?)<\/think>\s*/;
13
+
14
+ /** Tokens per second estimate for thinking content */
15
+ const THINKING_TOKENS_PER_SEC = 80;
16
+ /** Characters per token estimate */
17
+ const CHARS_PER_TOKEN = 4;
18
+
19
+ export interface ThinkTagResult {
20
+ /** The content inside <think> tags, or undefined if no tags found */
21
+ readonly thinkContent: string | undefined;
22
+ /** The remaining content after removing <think> tags */
23
+ readonly cleanContent: string;
24
+ }
25
+
26
+ /**
27
+ * Extract <think>...</think> block from the beginning of content.
28
+ * Returns the thinking content and cleaned content separately.
29
+ */
30
+ export function extractThinkTag(content: string): ThinkTagResult {
31
+ const match = content.match(THINK_TAG_REGEX);
32
+ if (!match) {
33
+ return { thinkContent: undefined, cleanContent: content };
34
+ }
35
+ return {
36
+ thinkContent: match[1].trim(),
37
+ cleanContent: content.slice(match[0].length),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Estimate thinking duration in milliseconds from thinking content.
43
+ * Uses a rough tokens-per-second heuristic since APIs don't report
44
+ * wall-clock thinking time.
45
+ */
46
+ export function estimateThinkingMs(thinkContent: string): number {
47
+ const estimatedTokens = Math.ceil(thinkContent.length / CHARS_PER_TOKEN);
48
+ const seconds = estimatedTokens / THINKING_TOKENS_PER_SEC;
49
+ return Math.round(seconds * 1000);
50
+ }
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { resolveAuth, extractToken } from '../src/auth/auth-resolver.js';
3
+
4
+ describe('extractToken', () => {
5
+ it('should extract apiKey for api-key auth', () => {
6
+ expect(extractToken({ type: 'api-key', apiKey: 'sk-123' })).toBe('sk-123');
7
+ });
8
+
9
+ it('should extract accessToken for oauth auth', () => {
10
+ expect(extractToken({
11
+ type: 'oauth',
12
+ clientId: 'cid',
13
+ clientSecret: 'cs',
14
+ tokenUrl: 'https://auth.example.com/token',
15
+ accessToken: 'at-123',
16
+ })).toBe('at-123');
17
+ });
18
+
19
+ it('should return empty string for oauth without accessToken', () => {
20
+ expect(extractToken({
21
+ type: 'oauth',
22
+ clientId: 'cid',
23
+ clientSecret: 'cs',
24
+ tokenUrl: 'https://auth.example.com/token',
25
+ })).toBe('');
26
+ });
27
+
28
+ it('should extract accessToken for azure-ad auth', () => {
29
+ expect(extractToken({
30
+ type: 'azure-ad',
31
+ tenantId: 'tid',
32
+ clientId: 'cid',
33
+ accessToken: 'az-token',
34
+ })).toBe('az-token');
35
+ });
36
+
37
+ it('should extract accessKeyId for aws-iam auth', () => {
38
+ expect(extractToken({
39
+ type: 'aws-iam',
40
+ region: 'us-east-1',
41
+ accessKeyId: 'AKIA123',
42
+ })).toBe('AKIA123');
43
+ });
44
+
45
+ it('should extract accessToken for gcp auth', () => {
46
+ expect(extractToken({
47
+ type: 'gcp-service-account',
48
+ projectId: 'proj-1',
49
+ accessToken: 'gcp-token',
50
+ })).toBe('gcp-token');
51
+ });
52
+
53
+ it('should return empty string for credential-file', () => {
54
+ expect(extractToken({
55
+ type: 'credential-file',
56
+ filePath: '/path/to/creds.json',
57
+ })).toBe('');
58
+ });
59
+ });
60
+
61
+ describe('resolveAuth', () => {
62
+ it('should resolve api-key auth', async () => {
63
+ const result = await resolveAuth({ type: 'api-key', apiKey: 'sk-test' });
64
+ expect(result.type).toBe('api-key');
65
+ expect(result.token).toBe('sk-test');
66
+ expect(result.headers['Authorization']).toBe('Bearer sk-test');
67
+ });
68
+
69
+ it('should resolve oauth with existing accessToken', async () => {
70
+ const result = await resolveAuth({
71
+ type: 'oauth',
72
+ clientId: 'cid',
73
+ clientSecret: 'cs',
74
+ tokenUrl: 'https://auth.example.com/token',
75
+ accessToken: 'existing-token',
76
+ });
77
+ expect(result.type).toBe('oauth');
78
+ expect(result.token).toBe('existing-token');
79
+ expect(result.headers['Authorization']).toBe('Bearer existing-token');
80
+ });
81
+
82
+ it('should resolve azure-ad with existing accessToken', async () => {
83
+ const result = await resolveAuth({
84
+ type: 'azure-ad',
85
+ tenantId: 'tid',
86
+ clientId: 'cid',
87
+ accessToken: 'az-token',
88
+ });
89
+ expect(result.type).toBe('azure-ad');
90
+ expect(result.token).toBe('az-token');
91
+ });
92
+
93
+ it('should throw on azure-ad without accessToken or clientSecret', async () => {
94
+ await expect(resolveAuth({
95
+ type: 'azure-ad',
96
+ tenantId: 'tid',
97
+ clientId: 'cid',
98
+ })).rejects.toThrow('Azure AD auth requires either accessToken or clientSecret');
99
+ });
100
+
101
+ it('should resolve aws-iam with credentials', async () => {
102
+ const result = await resolveAuth({
103
+ type: 'aws-iam',
104
+ region: 'us-east-1',
105
+ accessKeyId: 'AKIA123',
106
+ secretAccessKey: 'secret',
107
+ sessionToken: 'session',
108
+ });
109
+ expect(result.type).toBe('aws-iam');
110
+ expect(result.headers['x-aws-access-key-id']).toBe('AKIA123');
111
+ expect(result.headers['x-aws-region']).toBe('us-east-1');
112
+ expect(result.headers['x-aws-session-token']).toBe('session');
113
+ });
114
+
115
+ it('should resolve aws-iam without explicit credentials', async () => {
116
+ const result = await resolveAuth({
117
+ type: 'aws-iam',
118
+ region: 'us-west-2',
119
+ });
120
+ expect(result.type).toBe('aws-iam');
121
+ expect(result.headers).toEqual({});
122
+ });
123
+
124
+ it('should resolve gcp with accessToken', async () => {
125
+ const result = await resolveAuth({
126
+ type: 'gcp-service-account',
127
+ projectId: 'proj',
128
+ accessToken: 'gcp-tok',
129
+ });
130
+ expect(result.type).toBe('gcp-service-account');
131
+ expect(result.token).toBe('gcp-tok');
132
+ });
133
+
134
+ it('should resolve gcp without accessToken (ADC fallback)', async () => {
135
+ const result = await resolveAuth({
136
+ type: 'gcp-service-account',
137
+ projectId: 'proj',
138
+ });
139
+ expect(result.type).toBe('gcp-service-account');
140
+ expect(result.token).toBeUndefined();
141
+ });
142
+
143
+ it('should resolve credential-file auth', async () => {
144
+ const { writeFile, mkdir, rm } = await import('node:fs/promises');
145
+ const { join } = await import('node:path');
146
+ const { tmpdir } = await import('node:os');
147
+
148
+ const dir = join(tmpdir(), 'cli-agent-test-creds');
149
+ const filePath = join(dir, 'creds.json');
150
+
151
+ await mkdir(dir, { recursive: true });
152
+ await writeFile(filePath, JSON.stringify({
153
+ default: { api_key: 'file-key-123' },
154
+ staging: { token: 'staging-tok' },
155
+ }));
156
+
157
+ try {
158
+ const result = await resolveAuth({
159
+ type: 'credential-file',
160
+ filePath,
161
+ });
162
+ expect(result.type).toBe('credential-file');
163
+ expect(result.token).toBe('file-key-123');
164
+
165
+ const result2 = await resolveAuth({
166
+ type: 'credential-file',
167
+ filePath,
168
+ profile: 'staging',
169
+ });
170
+ expect(result2.token).toBe('staging-tok');
171
+ } finally {
172
+ await rm(dir, { recursive: true, force: true });
173
+ }
174
+ });
175
+
176
+ it('should throw for missing credential file', async () => {
177
+ await expect(resolveAuth({
178
+ type: 'credential-file',
179
+ filePath: '/nonexistent/path/creds.json',
180
+ })).rejects.toThrow('Failed to read credential file');
181
+ });
182
+
183
+ it('should throw for missing profile in credential file', async () => {
184
+ const { writeFile, mkdir, rm } = await import('node:fs/promises');
185
+ const { join } = await import('node:path');
186
+ const { tmpdir } = await import('node:os');
187
+
188
+ const dir = join(tmpdir(), 'cli-agent-test-creds-2');
189
+ const filePath = join(dir, 'creds.json');
190
+
191
+ await mkdir(dir, { recursive: true });
192
+ await writeFile(filePath, JSON.stringify({ default: { api_key: 'key' } }));
193
+
194
+ try {
195
+ await expect(resolveAuth({
196
+ type: 'credential-file',
197
+ filePath,
198
+ profile: 'nonexistent',
199
+ })).rejects.toThrow("Profile 'nonexistent' not found");
200
+ } finally {
201
+ await rm(dir, { recursive: true, force: true });
202
+ }
203
+ });
204
+ });