@llumiverse/drivers 1.0.0-dev.20260224.234313Z → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/lib/cjs/bedrock/converse.js +86 -12
  2. package/lib/cjs/bedrock/converse.js.map +1 -1
  3. package/lib/cjs/bedrock/index.js +208 -1
  4. package/lib/cjs/bedrock/index.js.map +1 -1
  5. package/lib/cjs/groq/index.js +7 -4
  6. package/lib/cjs/groq/index.js.map +1 -1
  7. package/lib/cjs/openai/index.js +457 -26
  8. package/lib/cjs/openai/index.js.map +1 -1
  9. package/lib/cjs/openai/openai_compatible.js +1 -0
  10. package/lib/cjs/openai/openai_compatible.js.map +1 -1
  11. package/lib/cjs/vertexai/index.js +42 -0
  12. package/lib/cjs/vertexai/index.js.map +1 -1
  13. package/lib/cjs/vertexai/models/claude.js +230 -2
  14. package/lib/cjs/vertexai/models/claude.js.map +1 -1
  15. package/lib/cjs/vertexai/models/gemini.js +261 -41
  16. package/lib/cjs/vertexai/models/gemini.js.map +1 -1
  17. package/lib/cjs/vertexai/models.js +1 -1
  18. package/lib/cjs/vertexai/models.js.map +1 -1
  19. package/lib/esm/bedrock/converse.js +80 -6
  20. package/lib/esm/bedrock/converse.js.map +1 -1
  21. package/lib/esm/bedrock/index.js +207 -2
  22. package/lib/esm/bedrock/index.js.map +1 -1
  23. package/lib/esm/groq/index.js +7 -4
  24. package/lib/esm/groq/index.js.map +1 -1
  25. package/lib/esm/openai/index.js +456 -27
  26. package/lib/esm/openai/index.js.map +1 -1
  27. package/lib/esm/openai/openai_compatible.js +1 -0
  28. package/lib/esm/openai/openai_compatible.js.map +1 -1
  29. package/lib/esm/vertexai/index.js +43 -1
  30. package/lib/esm/vertexai/index.js.map +1 -1
  31. package/lib/esm/vertexai/models/claude.js +229 -3
  32. package/lib/esm/vertexai/models/claude.js.map +1 -1
  33. package/lib/esm/vertexai/models/gemini.js +262 -43
  34. package/lib/esm/vertexai/models/gemini.js.map +1 -1
  35. package/lib/esm/vertexai/models.js +1 -1
  36. package/lib/esm/vertexai/models.js.map +1 -1
  37. package/lib/types/bedrock/converse.d.ts +1 -2
  38. package/lib/types/bedrock/converse.d.ts.map +1 -1
  39. package/lib/types/bedrock/index.d.ts +53 -1
  40. package/lib/types/bedrock/index.d.ts.map +1 -1
  41. package/lib/types/openai/index.d.ts +96 -1
  42. package/lib/types/openai/index.d.ts.map +1 -1
  43. package/lib/types/openai/openai_compatible.d.ts +5 -0
  44. package/lib/types/openai/openai_compatible.d.ts.map +1 -1
  45. package/lib/types/openai/openai_format.d.ts +1 -1
  46. package/lib/types/vertexai/index.d.ts +11 -1
  47. package/lib/types/vertexai/index.d.ts.map +1 -1
  48. package/lib/types/vertexai/models/claude.d.ts +64 -1
  49. package/lib/types/vertexai/models/claude.d.ts.map +1 -1
  50. package/lib/types/vertexai/models/gemini.d.ts +61 -1
  51. package/lib/types/vertexai/models/gemini.d.ts.map +1 -1
  52. package/lib/types/vertexai/models.d.ts +6 -1
  53. package/lib/types/vertexai/models.d.ts.map +1 -1
  54. package/package.json +9 -9
  55. package/src/bedrock/converse.ts +85 -10
  56. package/src/bedrock/error-handling.test.ts +352 -0
  57. package/src/bedrock/index.ts +225 -1
  58. package/src/groq/index.ts +9 -4
  59. package/src/openai/error-handling.test.ts +567 -0
  60. package/src/openai/index.ts +505 -29
  61. package/src/openai/openai_compatible.ts +7 -0
  62. package/src/openai/openai_format.ts +1 -1
  63. package/src/vertexai/index.ts +56 -5
  64. package/src/vertexai/models/claude-error-handling.test.ts +432 -0
  65. package/src/vertexai/models/claude.ts +273 -7
  66. package/src/vertexai/models/gemini-error-handling.test.ts +353 -0
  67. package/src/vertexai/models/gemini.ts +304 -48
  68. package/src/vertexai/models.ts +7 -2
@@ -1,14 +1,37 @@
1
+ import {
2
+ APIConnectionError,
3
+ APIConnectionTimeoutError,
4
+ APIError,
5
+ AuthenticationError,
6
+ BadRequestError,
7
+ ConflictError,
8
+ InternalServerError,
9
+ NotFoundError,
10
+ PermissionDeniedError,
11
+ RateLimitError,
12
+ UnprocessableEntityError,
13
+ } from '@anthropic-ai/sdk/error';
1
14
  import { ContentBlock, ContentBlockParam, DocumentBlockParam, ImageBlockParam, Message, MessageParam, TextBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources/index.js";
15
+ import { MessageStreamParams } from "@anthropic-ai/sdk/resources/index.mjs";
16
+ import { MessageCreateParamsBase, MessageCreateParamsNonStreaming, RawMessageStreamEvent } from "@anthropic-ai/sdk/resources/messages.js";
2
17
  import {
3
- AIModel, Completion, CompletionChunkObject, ExecutionOptions, getMaxTokensLimitVertexAi, JSONObject, ModelType,
4
- PromptRole, PromptSegment, readStreamAsBase64, readStreamAsString, StatelessExecutionOptions, ToolUse, VertexAIClaudeOptions,
5
- getConversationMeta, incrementConversationTurn, stripBase64ImagesFromConversation, truncateLargeTextInConversation
18
+ AIModel, Completion, CompletionChunkObject, ExecutionOptions,
19
+ getConversationMeta,
20
+ getMaxTokensLimitVertexAi,
21
+ incrementConversationTurn,
22
+ JSONObject,
23
+ LlumiverseError, LlumiverseErrorContext,
24
+ ModelType,
25
+ PromptRole, PromptSegment, readStreamAsBase64, readStreamAsString, StatelessExecutionOptions,
26
+ stripBase64ImagesFromConversation,
27
+ stripHeartbeatsFromConversation,
28
+ ToolUse,
29
+ truncateLargeTextInConversation,
30
+ VertexAIClaudeOptions
6
31
  } from "@llumiverse/core";
7
32
  import { asyncMap } from "@llumiverse/core/async";
8
33
  import { VertexAIDriver } from "../index.js";
9
34
  import { ModelDefinition } from "../models.js";
10
- import { MessageCreateParamsBase, MessageCreateParamsNonStreaming, RawMessageStreamEvent } from "@anthropic-ai/sdk/resources/messages.js";
11
- import { MessageStreamParams } from "@anthropic-ai/sdk/resources/index.mjs";
12
35
 
13
36
  export const ANTHROPIC_REGIONS: Record<string, string> = {
14
37
  us: "us-east5",
@@ -301,6 +324,10 @@ export class ClaudeModelDefinition implements ModelDefinition<ClaudePrompt> {
301
324
  };
302
325
  let processedConversation = stripBase64ImagesFromConversation(conversation, stripOptions);
303
326
  processedConversation = truncateLargeTextInConversation(processedConversation, stripOptions);
327
+ processedConversation = stripHeartbeatsFromConversation(processedConversation, {
328
+ keepForTurns: options.stripHeartbeatsAfterTurns ?? 1,
329
+ currentTurn,
330
+ });
304
331
 
305
332
  return {
306
333
  result: text ? [{ type: "text", value: text }] : [{ type: "text", value: '' }],
@@ -444,6 +471,170 @@ export class ClaudeModelDefinition implements ModelDefinition<ClaudePrompt> {
444
471
 
445
472
  return stream;
446
473
  }
474
+
475
+ /**
476
+ * Format Anthropic API errors into LlumiverseError with proper status codes and retryability.
477
+ *
478
+ * Anthropic API errors have a specific structure:
479
+ * - APIError.status: HTTP status code (400, 401, 403, 404, 409, 422, 429, 500+)
480
+ * - APIError.error: Nested error object with type and message
481
+ * - APIError.requestID: Request ID for support (can be null)
482
+ *
483
+ * Common error types:
484
+ * - BadRequestError (400): Invalid request parameters
485
+ * - AuthenticationError (401): Authentication required
486
+ * - PermissionDeniedError (403): Insufficient permissions
487
+ * - NotFoundError (404): Resource not found
488
+ * - ConflictError (409): Resource conflict
489
+ * - UnprocessableEntityError (422): Validation error
490
+ * - RateLimitError (429): Rate limit exceeded
491
+ * - InternalServerError (500+): Server-side errors
492
+ * - APIConnectionError: Connection issues (no status code)
493
+ * - APIConnectionTimeoutError: Request timeout (no status code)
494
+ *
495
+ * @see https://docs.anthropic.com/en/api/errors
496
+ */
497
+ formatLlumiverseError(
498
+ _driver: VertexAIDriver,
499
+ error: unknown,
500
+ context: LlumiverseErrorContext
501
+ ): LlumiverseError {
502
+ // Check if it's an Anthropic API error
503
+ const isAnthropicError = this.isAnthropicApiError(error);
504
+
505
+ if (!isAnthropicError) {
506
+ // Not an Anthropic API error, use default handling
507
+ throw error;
508
+ }
509
+
510
+ const apiError = error as APIError;
511
+ const httpStatusCode = apiError.status;
512
+
513
+ // Extract error message and nested error details
514
+ let message = apiError.message || String(error);
515
+
516
+ // Extract error type from nested error object if available
517
+ let errorType: string | undefined;
518
+ if (apiError.error && typeof apiError.error === 'object') {
519
+ const nestedError = apiError.error as any;
520
+ if (nestedError.error && typeof nestedError.error === 'object') {
521
+ errorType = nestedError.error.type;
522
+ // Use the nested error message if it's more specific
523
+ if (nestedError.error.message) {
524
+ message = nestedError.error.message;
525
+ }
526
+ }
527
+ }
528
+
529
+ // Build user-facing message with status code
530
+ let userMessage = message;
531
+
532
+ // Include status code in message (for end-user visibility)
533
+ if (httpStatusCode) {
534
+ userMessage = `[${httpStatusCode}] ${userMessage}`;
535
+ }
536
+
537
+ // Include error type if available
538
+ if (errorType && errorType !== 'error') {
539
+ userMessage = `${errorType}: ${userMessage}`;
540
+ }
541
+
542
+ // Add request ID if available (useful for Anthropic support)
543
+ if (apiError.requestID) {
544
+ userMessage += ` (Request ID: ${apiError.requestID})`;
545
+ }
546
+
547
+ // Determine retryability based on Anthropic error types
548
+ const retryable = this.isClaudeErrorRetryable(error, httpStatusCode, errorType);
549
+
550
+ // Use the error constructor name as the error name
551
+ const errorName = error.constructor?.name || 'AnthropicError';
552
+
553
+ return new LlumiverseError(
554
+ `[${context.provider}] ${userMessage}`,
555
+ retryable,
556
+ context,
557
+ error,
558
+ httpStatusCode,
559
+ errorName
560
+ );
561
+ }
562
+
563
+ /**
564
+ * Type guard to check if error is an Anthropic API error.
565
+ */
566
+ private isAnthropicApiError(error: unknown): error is APIError {
567
+ return (
568
+ error !== null &&
569
+ typeof error === 'object' &&
570
+ error instanceof APIError
571
+ );
572
+ }
573
+
574
+ /**
575
+ * Determine if an Anthropic API error is retryable.
576
+ *
577
+ * Retryable errors:
578
+ * - RateLimitError (429): Rate limit exceeded, retry with backoff
579
+ * - InternalServerError (500+): Server-side errors
580
+ * - APIConnectionTimeoutError: Request timeout
581
+ * - 408 (Request Timeout): Request timeout
582
+ * - 529 (Overloaded): Service overloaded
583
+ *
584
+ * Non-retryable errors:
585
+ * - BadRequestError (400): Invalid request parameters
586
+ * - AuthenticationError (401): Authentication failure
587
+ * - PermissionDeniedError (403): Insufficient permissions
588
+ * - NotFoundError (404): Resource not found
589
+ * - ConflictError (409): Resource conflict
590
+ * - UnprocessableEntityError (422): Validation error
591
+ * - Other 4xx client errors
592
+ * - invalid_request_error: Invalid request structure
593
+ *
594
+ * @param error - The error object
595
+ * @param httpStatusCode - The HTTP status code if available
596
+ * @param errorType - The nested error type if available
597
+ * @returns True if retryable, false if not retryable, undefined if unknown
598
+ */
599
+ private isClaudeErrorRetryable(
600
+ error: unknown,
601
+ httpStatusCode: number | undefined,
602
+ errorType: string | undefined
603
+ ): boolean | undefined {
604
+ // Check specific Anthropic error types by class
605
+ if (error instanceof RateLimitError) return true;
606
+ if (error instanceof InternalServerError) return true;
607
+ if (error instanceof APIConnectionTimeoutError) return true;
608
+
609
+ // Non-retryable by error type
610
+ if (error instanceof BadRequestError) return false;
611
+ if (error instanceof AuthenticationError) return false;
612
+ if (error instanceof PermissionDeniedError) return false;
613
+ if (error instanceof NotFoundError) return false;
614
+ if (error instanceof ConflictError) return false;
615
+ if (error instanceof UnprocessableEntityError) return false;
616
+
617
+ // Check nested error type
618
+ if (errorType === 'invalid_request_error') return false;
619
+
620
+ // Use HTTP status code
621
+ if (httpStatusCode !== undefined) {
622
+ if (httpStatusCode === 429) return true; // Rate limit
623
+ if (httpStatusCode === 408) return true; // Request timeout
624
+ if (httpStatusCode === 529) return true; // Overloaded
625
+ if (httpStatusCode >= 500 && httpStatusCode < 600) return true; // Server errors
626
+ if (httpStatusCode >= 400 && httpStatusCode < 500) return false; // Client errors
627
+ }
628
+
629
+ // Connection errors without status codes
630
+ if (error instanceof APIConnectionError && !(error instanceof APIConnectionTimeoutError)) {
631
+ // Generic connection errors might be retryable (network issues)
632
+ return true;
633
+ }
634
+
635
+ // Unknown error type - let consumer decide retry strategy
636
+ return undefined;
637
+ }
447
638
  }
448
639
 
449
640
  function createPromptFromResponse(response: Message): ClaudePrompt {
@@ -679,7 +870,7 @@ function getClaudePayload(options: ExecutionOptions, prompt: ClaudePrompt): { pa
679
870
  // Fix orphaned tool_use blocks (can occur when agent is stopped mid-tool-execution)
680
871
  const fixedMessages = fixOrphanedToolUse(prompt.messages);
681
872
  // Sanitize messages to remove empty text blocks (can occur from interrupted streaming)
682
- const sanitizedMessages = sanitizeMessages(fixedMessages);
873
+ let sanitizedMessages = sanitizeMessages(fixedMessages);
683
874
 
684
875
  // Validate tools have input_schema.type set to 'object' as required by the Anthropic SDK
685
876
  if (options.tools) {
@@ -690,10 +881,17 @@ function getClaudePayload(options: ExecutionOptions, prompt: ClaudePrompt): { pa
690
881
  }
691
882
  }
692
883
 
884
+ // When no tools are provided but conversation contains tool_use/tool_result blocks
885
+ // (e.g. checkpoint summary calls), convert tool blocks to text to avoid API errors
886
+ const hasTools = options.tools && options.tools.length > 0;
887
+ if (!hasTools && claudeMessagesContainToolBlocks(sanitizedMessages)) {
888
+ sanitizedMessages = convertClaudeToolBlocksToText(sanitizedMessages);
889
+ }
890
+
693
891
  const payload = {
694
892
  messages: sanitizedMessages,
695
893
  system: prompt.system,
696
- tools: options.tools as MessageCreateParamsBase['tools'],
894
+ tools: hasTools ? options.tools as MessageCreateParamsBase['tools'] : undefined,
697
895
  temperature: model_options?.temperature,
698
896
  model: modelName,
699
897
  max_tokens: maxToken(options),
@@ -711,3 +909,71 @@ function getClaudePayload(options: ExecutionOptions, prompt: ClaudePrompt): { pa
711
909
 
712
910
  return { payload, requestOptions };
713
911
  }
912
+
913
+ /**
914
+ * Checks whether any Claude message contains tool_use or tool_result content blocks.
915
+ */
916
+ export function claudeMessagesContainToolBlocks(messages: MessageParam[]): boolean {
917
+ for (const msg of messages) {
918
+ if (!Array.isArray(msg.content)) continue;
919
+ for (const block of msg.content) {
920
+ if (typeof block === 'object' && block !== null && 'type' in block) {
921
+ if (block.type === 'tool_use' || block.type === 'tool_result') return true;
922
+ }
923
+ }
924
+ }
925
+ return false;
926
+ }
927
+
928
+ /**
929
+ * Converts tool_use and tool_result blocks to text in Claude messages.
930
+ * Preserves tool call information while removing structured blocks that
931
+ * require tools to be defined in the API request.
932
+ */
933
+ export function convertClaudeToolBlocksToText(messages: MessageParam[]): MessageParam[] {
934
+ return messages.map(msg => {
935
+ if (!Array.isArray(msg.content)) return msg;
936
+ let hasToolBlocks = false;
937
+ for (const block of msg.content) {
938
+ if (typeof block === 'object' && block !== null && 'type' in block &&
939
+ (block.type === 'tool_use' || block.type === 'tool_result')) {
940
+ hasToolBlocks = true;
941
+ break;
942
+ }
943
+ }
944
+ if (!hasToolBlocks) return msg;
945
+
946
+ const newContent: MessageParam['content'] = [];
947
+ for (const block of msg.content) {
948
+ if (typeof block === 'string') {
949
+ newContent.push(block);
950
+ continue;
951
+ }
952
+ if (block.type === 'tool_use') {
953
+ const inputStr = block.input ? JSON.stringify(block.input) : '';
954
+ const truncated = inputStr.length > 500 ? inputStr.substring(0, 500) + '...' : inputStr;
955
+ (newContent as Array<{ type: 'text'; text: string }>).push({
956
+ type: 'text',
957
+ text: `[Tool call: ${block.name}(${truncated})]`,
958
+ });
959
+ } else if (block.type === 'tool_result') {
960
+ let resultStr = 'No content';
961
+ if (typeof block.content === 'string') {
962
+ resultStr = block.content.length > 500 ? block.content.substring(0, 500) + '...' : block.content;
963
+ } else if (Array.isArray(block.content)) {
964
+ const texts = block.content
965
+ .filter((c): c is { type: 'text'; text: string } => c.type === 'text')
966
+ .map(c => c.text.length > 500 ? c.text.substring(0, 500) + '...' : c.text);
967
+ resultStr = texts.join('\n') || 'No text content';
968
+ }
969
+ (newContent as Array<{ type: 'text'; text: string }>).push({
970
+ type: 'text',
971
+ text: `[Tool result: ${resultStr}]`,
972
+ });
973
+ } else {
974
+ newContent.push(block as any);
975
+ }
976
+ }
977
+ return { ...msg, content: newContent };
978
+ });
979
+ }
@@ -0,0 +1,353 @@
1
+ import { LlumiverseError } from '@llumiverse/core';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { VertexAIDriver } from '../index.js';
4
+ import { GeminiModelDefinition } from './gemini.js';
5
+
6
+ describe('GeminiModelDefinition Error Handling', () => {
7
+ let driver: VertexAIDriver;
8
+ let modelDef: GeminiModelDefinition;
9
+
10
+ beforeEach(() => {
11
+ driver = new VertexAIDriver({
12
+ project: 'test-project',
13
+ region: 'us-central1',
14
+ });
15
+ modelDef = new GeminiModelDefinition('gemini-2.0-flash');
16
+ });
17
+
18
+ describe('formatLlumiverseError', () => {
19
+ it('should handle INVALID_ARGUMENT error (400)', () => {
20
+ const googleError = {
21
+ status: 400,
22
+ message: 'INVALID_ARGUMENT: Invalid value for temperature. Must be between 0 and 2.',
23
+ };
24
+
25
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
26
+ provider: 'vertexai',
27
+ model: 'gemini-2.0-flash',
28
+ operation: 'execute',
29
+ });
30
+
31
+ expect(error).toBeInstanceOf(LlumiverseError);
32
+ expect(error.code).toBe(400);
33
+ expect(error.retryable).toBe(false);
34
+ expect(error.message).toContain('[400]');
35
+ expect(error.message).toContain('Invalid value for temperature');
36
+ expect(error.name).toBe('INVALID_ARGUMENT');
37
+ expect(error.context.provider).toBe('vertexai');
38
+ });
39
+
40
+ it('should handle UNAUTHENTICATED error (401)', () => {
41
+ const googleError = {
42
+ status: 401,
43
+ message: 'UNAUTHENTICATED: Request had invalid authentication credentials.',
44
+ };
45
+
46
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
47
+ provider: 'vertexai',
48
+ model: 'gemini-2.0-flash',
49
+ operation: 'execute',
50
+ });
51
+
52
+ expect(error.retryable).toBe(false);
53
+ expect(error.code).toBe(401);
54
+ expect(error.name).toBe('UNAUTHENTICATED');
55
+ });
56
+
57
+ it('should handle PERMISSION_DENIED error (403)', () => {
58
+ const googleError = {
59
+ status: 403,
60
+ message: 'PERMISSION_DENIED: The caller does not have permission to execute the specified operation.',
61
+ };
62
+
63
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
64
+ provider: 'vertexai',
65
+ model: 'gemini-2.0-flash',
66
+ operation: 'execute',
67
+ });
68
+
69
+ expect(error.retryable).toBe(false);
70
+ expect(error.code).toBe(403);
71
+ expect(error.name).toBe('PERMISSION_DENIED');
72
+ });
73
+
74
+ it('should handle NOT_FOUND error (404)', () => {
75
+ const googleError = {
76
+ status: 404,
77
+ message: 'NOT_FOUND: Requested entity was not found.',
78
+ };
79
+
80
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
81
+ provider: 'vertexai',
82
+ model: 'gemini-2.0-flash',
83
+ operation: 'execute',
84
+ });
85
+
86
+ expect(error.retryable).toBe(false);
87
+ expect(error.code).toBe(404);
88
+ expect(error.name).toBe('NOT_FOUND');
89
+ });
90
+
91
+ it('should handle RESOURCE_EXHAUSTED error (429) as retryable', () => {
92
+ const googleError = {
93
+ status: 429,
94
+ message: 'RESOURCE_EXHAUSTED: Quota exceeded for quota metric',
95
+ };
96
+
97
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
98
+ provider: 'vertexai',
99
+ model: 'gemini-2.0-flash',
100
+ operation: 'execute',
101
+ });
102
+
103
+ expect(error.retryable).toBe(true);
104
+ expect(error.code).toBe(429);
105
+ expect(error.message).toContain('[429]');
106
+ expect(error.name).toBe('RESOURCE_EXHAUSTED');
107
+ });
108
+
109
+ it('should handle INTERNAL error (500) as retryable', () => {
110
+ const googleError = {
111
+ status: 500,
112
+ message: 'INTERNAL: Internal server error',
113
+ };
114
+
115
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
116
+ provider: 'vertexai',
117
+ model: 'gemini-2.0-flash',
118
+ operation: 'execute',
119
+ });
120
+
121
+ expect(error.retryable).toBe(true);
122
+ expect(error.code).toBe(500);
123
+ expect(error.message).toContain('[500]');
124
+ });
125
+
126
+ it('should handle BAD_GATEWAY error (502) as retryable', () => {
127
+ const googleError = {
128
+ status: 502,
129
+ message: 'Bad gateway',
130
+ };
131
+
132
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
133
+ provider: 'vertexai',
134
+ model: 'gemini-2.0-flash',
135
+ operation: 'execute',
136
+ });
137
+
138
+ expect(error.retryable).toBe(true);
139
+ expect(error.code).toBe(502);
140
+ });
141
+
142
+ it('should handle UNAVAILABLE error (503) as retryable', () => {
143
+ const googleError = {
144
+ status: 503,
145
+ message: 'UNAVAILABLE: The service is currently unavailable.',
146
+ };
147
+
148
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
149
+ provider: 'vertexai',
150
+ model: 'gemini-2.0-flash',
151
+ operation: 'execute',
152
+ });
153
+
154
+ expect(error.retryable).toBe(true);
155
+ expect(error.code).toBe(503);
156
+ expect(error.name).toBe('UNAVAILABLE');
157
+ });
158
+
159
+ it('should handle DEADLINE_EXCEEDED error (504) as retryable', () => {
160
+ const googleError = {
161
+ status: 504,
162
+ message: 'DEADLINE_EXCEEDED: Request timeout',
163
+ };
164
+
165
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
166
+ provider: 'vertexai',
167
+ model: 'gemini-2.0-flash',
168
+ operation: 'execute',
169
+ });
170
+
171
+ expect(error.retryable).toBe(true);
172
+ expect(error.code).toBe(504);
173
+ expect(error.name).toBe('DEADLINE_EXCEEDED');
174
+ });
175
+
176
+ it('should handle REQUEST_TIMEOUT error (408) as retryable', () => {
177
+ const googleError = {
178
+ status: 408,
179
+ message: 'Request timeout',
180
+ };
181
+
182
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
183
+ provider: 'vertexai',
184
+ model: 'gemini-2.0-flash',
185
+ operation: 'execute',
186
+ });
187
+
188
+ expect(error.retryable).toBe(true);
189
+ expect(error.code).toBe(408);
190
+ });
191
+
192
+ it('should preserve original error for debugging', () => {
193
+ const googleError = {
194
+ status: 429,
195
+ message: 'RESOURCE_EXHAUSTED: Quota exceeded',
196
+ };
197
+
198
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
199
+ provider: 'vertexai',
200
+ model: 'gemini-2.0-flash',
201
+ operation: 'execute',
202
+ });
203
+
204
+ expect(error.originalError).toBe(googleError);
205
+ expect((error.originalError as any).status).toBe(429);
206
+ });
207
+
208
+ it('should throw for non-Google API errors', () => {
209
+ const regularError = new Error('Regular error');
210
+
211
+ expect(() => {
212
+ modelDef.formatLlumiverseError(driver, regularError, {
213
+ provider: 'vertexai',
214
+ model: 'gemini-2.0-flash',
215
+ operation: 'execute',
216
+ });
217
+ }).toThrow();
218
+ });
219
+
220
+ it('should extract error name from bracket format', () => {
221
+ const googleError = {
222
+ status: 400,
223
+ message: '[INVALID_ARGUMENT] Invalid parameter',
224
+ };
225
+
226
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
227
+ provider: 'vertexai',
228
+ model: 'gemini-2.0-flash',
229
+ operation: 'execute',
230
+ });
231
+
232
+ expect(error.name).toBe('INVALID_ARGUMENT');
233
+ });
234
+
235
+ it('should extract error name from Error suffix format', () => {
236
+ const googleError = {
237
+ status: 400,
238
+ message: 'ValidationError: Invalid input',
239
+ };
240
+
241
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
242
+ provider: 'vertexai',
243
+ model: 'gemini-2.0-flash',
244
+ operation: 'execute',
245
+ });
246
+
247
+ expect(error.name).toBe('ValidationError');
248
+ });
249
+
250
+ it('should handle errors without extractable name', () => {
251
+ const googleError = {
252
+ status: 500,
253
+ message: 'Something went wrong',
254
+ };
255
+
256
+ const error = modelDef.formatLlumiverseError(driver, googleError, {
257
+ provider: 'vertexai',
258
+ model: 'gemini-2.0-flash',
259
+ operation: 'execute',
260
+ });
261
+
262
+ // When no name is extracted, defaults to 'LlumiverseError'
263
+ expect(error.name).toBe('LlumiverseError');
264
+ expect(error.code).toBe(500);
265
+ });
266
+ });
267
+
268
+ describe('isGeminiErrorRetryable', () => {
269
+ it('should classify retryable status codes correctly', () => {
270
+ const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
271
+
272
+ retryableStatusCodes.forEach((statusCode) => {
273
+ const result = (modelDef as any).isGeminiErrorRetryable(statusCode);
274
+ expect(result, `Status code ${statusCode} should be retryable`).toBe(true);
275
+ });
276
+ });
277
+
278
+ it('should classify non-retryable status codes correctly', () => {
279
+ const nonRetryableStatusCodes = [400, 401, 403, 404, 409];
280
+
281
+ nonRetryableStatusCodes.forEach((statusCode) => {
282
+ const result = (modelDef as any).isGeminiErrorRetryable(statusCode);
283
+ expect(result, `Status code ${statusCode} should not be retryable`).toBe(false);
284
+ });
285
+ });
286
+
287
+ it('should classify other 5xx errors as retryable', () => {
288
+ expect((modelDef as any).isGeminiErrorRetryable(501)).toBe(true);
289
+ expect((modelDef as any).isGeminiErrorRetryable(505)).toBe(true);
290
+ expect((modelDef as any).isGeminiErrorRetryable(599)).toBe(true);
291
+ });
292
+
293
+ it('should classify other 4xx errors as non-retryable', () => {
294
+ expect((modelDef as any).isGeminiErrorRetryable(402)).toBe(false);
295
+ expect((modelDef as any).isGeminiErrorRetryable(405)).toBe(false);
296
+ expect((modelDef as any).isGeminiErrorRetryable(499)).toBe(false);
297
+ });
298
+ });
299
+
300
+ describe('VertexAIDriver error routing', () => {
301
+ it('should route to Gemini-specific error handler', () => {
302
+ const googleError = {
303
+ status: 429,
304
+ message: 'RESOURCE_EXHAUSTED: Quota exceeded',
305
+ };
306
+
307
+ const error = driver.formatLlumiverseError(googleError, {
308
+ provider: 'vertexai',
309
+ model: 'gemini-2.0-flash',
310
+ operation: 'execute',
311
+ });
312
+
313
+ expect(error).toBeInstanceOf(LlumiverseError);
314
+ expect(error.code).toBe(429);
315
+ expect(error.retryable).toBe(true);
316
+ expect(error.name).toBe('RESOURCE_EXHAUSTED');
317
+ });
318
+
319
+ it('should fall back to default handler for non-Google errors', () => {
320
+ const regularError = new Error('Regular error');
321
+
322
+ const error = driver.formatLlumiverseError(regularError, {
323
+ provider: 'vertexai',
324
+ model: 'gemini-2.0-flash',
325
+ operation: 'execute',
326
+ });
327
+
328
+ expect(error).toBeInstanceOf(LlumiverseError);
329
+ expect(error.code).toBeUndefined();
330
+ expect(error.retryable).toBeUndefined(); // Unknown errors - let consumer decide
331
+ });
332
+
333
+ it('should work with different Gemini model versions', () => {
334
+ const googleError = {
335
+ status: 400,
336
+ message: 'INVALID_ARGUMENT: Invalid parameter',
337
+ };
338
+
339
+ const models = ['gemini-2.0-flash', 'gemini-2.5-flash', 'gemini-1.5-pro'];
340
+
341
+ models.forEach((model) => {
342
+ const error = driver.formatLlumiverseError(googleError, {
343
+ provider: 'vertexai',
344
+ model,
345
+ operation: 'execute',
346
+ });
347
+
348
+ expect(error.code).toBe(400);
349
+ expect(error.retryable).toBe(false);
350
+ });
351
+ });
352
+ });
353
+ });