@vybestack/llxprt-code-core 0.7.0-nightly.251218.3619c584b → 0.7.0-nightly.251218.47baadc14

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.
@@ -25,7 +25,6 @@ import * as net from 'net';
25
25
  import { isKimiModel, isMistralModel, getToolIdStrategy, } from '../../tools/ToolIdStrategy.js';
26
26
  import { BaseProvider, } from '../BaseProvider.js';
27
27
  import { DebugLogger } from '../../debug/index.js';
28
- import { flushRuntimeAuthScope, } from '../../auth/precedence.js';
29
28
  import { ToolFormatter } from '../../tools/ToolFormatter.js';
30
29
  import { convertToolsToOpenAI } from './schemaConverter.js';
31
30
  import { GemmaToolCallParser } from '../../parsers/TextToolCallParser.js';
@@ -37,12 +36,15 @@ import { resolveRuntimeAuthToken } from '../utils/authToken.js';
37
36
  import { filterOpenAIRequestParams } from './openaiRequestParams.js';
38
37
  import { ensureJsonSafe } from '../../utils/unicodeUtils.js';
39
38
  import { ToolCallPipeline } from './ToolCallPipeline.js';
40
- import { buildToolResponsePayload } from '../utils/toolResponsePayload.js';
39
+ import { buildToolResponsePayload, EMPTY_TOOL_RESULT_PLACEHOLDER, } from '../utils/toolResponsePayload.js';
41
40
  import { isLocalEndpoint } from '../utils/localEndpoint.js';
42
41
  import { filterThinkingForContext, thinkingToReasoningField, extractThinkingBlocks, } from '../reasoning/reasoningUtils.js';
43
42
  import { shouldDumpSDKContext, dumpSDKContext, } from '../utils/dumpSDKContext.js';
44
43
  import { extractCacheMetrics } from '../utils/cacheMetricsExtractor.js';
44
+ const MAX_TOOL_RESPONSE_CHARS = 1024;
45
+ const MAX_TOOL_RESPONSE_RETRY_CHARS = 512;
45
46
  const TOOL_ARGS_PREVIEW_LENGTH = 500;
47
+ const TEXTUAL_TOOL_REPLAY_MODELS = new Set(['openrouter/polaris-alpha']);
46
48
  export class OpenAIProvider extends BaseProvider {
47
49
  textToolParser = new GemmaToolCallParser();
48
50
  toolCallPipeline = new ToolCallPipeline();
@@ -50,38 +52,6 @@ export class OpenAIProvider extends BaseProvider {
50
52
  getLogger() {
51
53
  return new DebugLogger('llxprt:provider:openai');
52
54
  }
53
- async handleBucketFailoverOnPersistent429(options, logger) {
54
- const failoverHandler = options.runtime?.config?.getBucketFailoverHandler();
55
- if (!failoverHandler || !failoverHandler.isEnabled()) {
56
- return { result: null };
57
- }
58
- logger.debug(() => 'Attempting bucket failover on persistent 429');
59
- const success = await failoverHandler.tryFailover();
60
- if (!success) {
61
- logger.debug(() => 'Bucket failover failed - no more buckets available');
62
- return { result: false };
63
- }
64
- const previousAuthToken = options.resolved.authToken;
65
- try {
66
- // Clear runtime-scoped auth cache so subsequent auth resolution can pick up the new bucket.
67
- if (typeof options.runtime?.runtimeId === 'string') {
68
- flushRuntimeAuthScope(options.runtime.runtimeId);
69
- }
70
- // Force re-resolution of the auth token after bucket failover.
71
- options.resolved.authToken = '';
72
- const refreshedAuthToken = await this.getAuthTokenForPrompt();
73
- options.resolved.authToken = refreshedAuthToken;
74
- // Rebuild client with fresh credentials from new bucket
75
- const client = await this.getClient(options);
76
- logger.debug(() => `Bucket failover successful, new bucket: ${failoverHandler.getCurrentBucket()}`);
77
- return { result: true, client };
78
- }
79
- catch (error) {
80
- options.resolved.authToken = previousAuthToken;
81
- logger.debug(() => `Bucket failover auth refresh failed: ${error instanceof Error ? error.message : String(error)}`);
82
- return { result: false };
83
- }
84
- }
85
55
  /**
86
56
  * @plan:PLAN-20251023-STATELESS-HARDENING.P08
87
57
  * @requirement:REQ-SP4-003
@@ -857,6 +827,39 @@ export class OpenAIProvider extends BaseProvider {
857
827
  }
858
828
  return JSON.stringify({ value: parameters });
859
829
  }
830
+ determineToolReplayMode(model) {
831
+ if (!model) {
832
+ return 'native';
833
+ }
834
+ const normalized = model.toLowerCase();
835
+ if (TEXTUAL_TOOL_REPLAY_MODELS.has(normalized)) {
836
+ return 'textual';
837
+ }
838
+ return 'native';
839
+ }
840
+ describeToolCallForText(block) {
841
+ const normalizedArgs = this.normalizeToolCallArguments(block.parameters);
842
+ const preview = normalizedArgs.length > MAX_TOOL_RESPONSE_CHARS
843
+ ? `${normalizedArgs.slice(0, MAX_TOOL_RESPONSE_CHARS)}… [truncated ${normalizedArgs.length - MAX_TOOL_RESPONSE_CHARS} chars]`
844
+ : normalizedArgs;
845
+ const callId = block.id ? ` ${this.normalizeToOpenAIToolId(block.id)}` : '';
846
+ return `[TOOL CALL${callId ? ` ${callId}` : ''}] ${block.name ?? 'unknown_tool'} args=${preview}`;
847
+ }
848
+ describeToolResponseForText(block, config) {
849
+ const payload = buildToolResponsePayload(block, config);
850
+ const header = `[TOOL RESULT] ${payload.toolName ?? block.toolName ?? 'unknown_tool'} (${payload.status ?? 'unknown'})`;
851
+ const bodyParts = [];
852
+ if (payload.error) {
853
+ bodyParts.push(`error: ${payload.error}`);
854
+ }
855
+ if (payload.result && payload.result !== EMPTY_TOOL_RESULT_PLACEHOLDER) {
856
+ bodyParts.push(payload.result);
857
+ }
858
+ if (payload.limitMessage) {
859
+ bodyParts.push(payload.limitMessage);
860
+ }
861
+ return bodyParts.length > 0 ? `${header}\n${bodyParts.join('\n')}` : header;
862
+ }
860
863
  buildToolResponseContent(block, config) {
861
864
  const payload = buildToolResponsePayload(block, config);
862
865
  return ensureJsonSafe(JSON.stringify(payload));
@@ -908,6 +911,107 @@ export class OpenAIProvider extends BaseProvider {
908
911
  });
909
912
  return modified;
910
913
  }
914
+ /**
915
+ * Convert IContent array to OpenAI ChatCompletionMessageParam array
916
+ */
917
+ convertToOpenAIMessages(contents, mode = 'native', config) {
918
+ const messages = [];
919
+ for (const content of contents) {
920
+ if (content.speaker === 'human') {
921
+ // Convert human messages to user messages
922
+ const textBlocks = content.blocks.filter((b) => b.type === 'text');
923
+ const text = textBlocks.map((b) => b.text).join('\n');
924
+ if (text) {
925
+ messages.push({
926
+ role: 'user',
927
+ content: text,
928
+ });
929
+ }
930
+ }
931
+ else if (content.speaker === 'ai') {
932
+ // Convert AI messages
933
+ const textBlocks = content.blocks.filter((b) => b.type === 'text');
934
+ const text = textBlocks.map((b) => b.text).join('\n');
935
+ const toolCalls = content.blocks.filter((b) => b.type === 'tool_call');
936
+ if (toolCalls.length > 0) {
937
+ if (mode === 'textual') {
938
+ const segments = [];
939
+ if (text) {
940
+ segments.push(text);
941
+ }
942
+ for (const tc of toolCalls) {
943
+ segments.push(this.describeToolCallForText(tc));
944
+ }
945
+ const combined = segments.join('\n\n').trim();
946
+ if (combined) {
947
+ messages.push({
948
+ role: 'assistant',
949
+ content: combined,
950
+ });
951
+ }
952
+ }
953
+ else {
954
+ // Assistant message with tool calls
955
+ // CRITICAL for Mistral API compatibility (#760):
956
+ // When tool_calls are present, we must NOT include a content property at all
957
+ // (not even null). Mistral's OpenAI-compatible API requires this.
958
+ // See: https://docs.mistral.ai/capabilities/function_calling
959
+ messages.push({
960
+ role: 'assistant',
961
+ tool_calls: toolCalls.map((tc) => ({
962
+ id: this.normalizeToOpenAIToolId(tc.id),
963
+ type: 'function',
964
+ function: {
965
+ name: tc.name,
966
+ arguments: this.normalizeToolCallArguments(tc.parameters),
967
+ },
968
+ })),
969
+ });
970
+ }
971
+ }
972
+ else if (textBlocks.length > 0) {
973
+ // Plain assistant message
974
+ messages.push({
975
+ role: 'assistant',
976
+ content: text,
977
+ });
978
+ }
979
+ }
980
+ else if (content.speaker === 'tool') {
981
+ // Convert tool responses
982
+ const toolResponses = content.blocks.filter((b) => b.type === 'tool_response');
983
+ if (mode === 'textual') {
984
+ const segments = toolResponses
985
+ .map((tr) => this.describeToolResponseForText(tr, config))
986
+ .filter(Boolean);
987
+ if (segments.length > 0) {
988
+ messages.push({
989
+ role: 'user',
990
+ content: segments.join('\n\n'),
991
+ });
992
+ }
993
+ }
994
+ else {
995
+ for (const tr of toolResponses) {
996
+ // CRITICAL for Mistral API compatibility (#760):
997
+ // Tool messages must include a name field matching the function name.
998
+ // See: https://docs.mistral.ai/capabilities/function_calling
999
+ // Note: The OpenAI SDK types don't include name, but Mistral requires it.
1000
+ // We use a type assertion to add this required field.
1001
+ messages.push({
1002
+ role: 'tool',
1003
+ content: this.buildToolResponseContent(tr, config),
1004
+ tool_call_id: this.normalizeToOpenAIToolId(tr.callId),
1005
+ name: tr.toolName,
1006
+ });
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ // Validate tool message sequence to prevent API errors
1012
+ // This ensures each tool message has a corresponding tool_calls in previous message
1013
+ return this.validateToolMessageSequence(messages);
1014
+ }
911
1015
  /**
912
1016
  * Build messages with optional reasoning_content based on settings.
913
1017
  *
@@ -1162,6 +1266,7 @@ export class OpenAIProvider extends BaseProvider {
1162
1266
  async *generateLegacyChatCompletionImpl(options, toolFormatter, client, logger) {
1163
1267
  const { contents, tools, metadata } = options;
1164
1268
  const model = options.resolved.model || this.getDefaultModel();
1269
+ const toolReplayMode = this.determineToolReplayMode(model);
1165
1270
  const abortSignal = metadata?.abortSignal;
1166
1271
  const ephemeralSettings = options.invocation?.ephemerals ?? {};
1167
1272
  if (logger.enabled) {
@@ -1189,7 +1294,12 @@ export class OpenAIProvider extends BaseProvider {
1189
1294
  // Convert IContent to OpenAI messages format
1190
1295
  // Use buildMessagesWithReasoning for reasoning-aware message building
1191
1296
  // Pass detectedFormat so that Kimi K2 tool IDs are generated correctly
1192
- const messages = this.buildMessagesWithReasoning(contents, options, detectedFormat);
1297
+ const messages = toolReplayMode === 'native'
1298
+ ? this.buildMessagesWithReasoning(contents, options, detectedFormat)
1299
+ : this.convertToOpenAIMessages(contents, toolReplayMode, options.config ?? options.runtime?.config ?? this.globalConfig);
1300
+ if (logger.enabled && toolReplayMode !== 'native') {
1301
+ logger.debug(() => `[OpenAIProvider] Using textual tool replay mode for model '${model}'`);
1302
+ }
1193
1303
  // Convert Gemini format tools to OpenAI format using the schema converter
1194
1304
  // This ensures required fields are always present in tool schemas
1195
1305
  let formattedTools = convertToolsToOpenAI(tools);
@@ -1384,11 +1494,22 @@ export class OpenAIProvider extends BaseProvider {
1384
1494
  // Bucket failover callback for 429 errors
1385
1495
  // @plan PLAN-20251213issue686 Bucket failover integration for OpenAIProvider
1386
1496
  const onPersistent429Callback = async () => {
1387
- const { result, client } = await this.handleBucketFailoverOnPersistent429(options, logger);
1388
- if (client) {
1389
- failoverClient = client;
1390
- }
1391
- return result;
1497
+ // Try to get the bucket failover handler from runtime context config
1498
+ const failoverHandler = options.runtime?.config?.getBucketFailoverHandler();
1499
+ if (failoverHandler && failoverHandler.isEnabled()) {
1500
+ logger.debug(() => 'Attempting bucket failover on persistent 429');
1501
+ const success = await failoverHandler.tryFailover();
1502
+ if (success) {
1503
+ // Rebuild client with fresh credentials from new bucket
1504
+ failoverClient = await this.getClient(options);
1505
+ logger.debug(() => `Bucket failover successful, new bucket: ${failoverHandler.getCurrentBucket()}`);
1506
+ return true; // Signal retry with new bucket
1507
+ }
1508
+ logger.debug(() => 'Bucket failover failed - no more buckets available');
1509
+ return false; // No more buckets, stop retrying
1510
+ }
1511
+ // No bucket failover configured
1512
+ return null;
1392
1513
  };
1393
1514
  // Use failover client if bucket failover happened, otherwise use original client
1394
1515
  const executeRequest = () => {
@@ -1447,7 +1568,7 @@ export class OpenAIProvider extends BaseProvider {
1447
1568
  }
1448
1569
  if (!compressedOnce &&
1449
1570
  this.shouldCompressToolMessages(error, logger) &&
1450
- this.compressToolMessages(requestBody.messages, 512, logger)) {
1571
+ this.compressToolMessages(requestBody.messages, MAX_TOOL_RESPONSE_RETRY_CHARS, logger)) {
1451
1572
  compressedOnce = true;
1452
1573
  logger.warn(() => `[OpenAIProvider] Retrying request after compressing tool responses due to provider 400`);
1453
1574
  continue;
@@ -2351,6 +2472,8 @@ export class OpenAIProvider extends BaseProvider {
2351
2472
  metadataKeys: Object.keys(metadata ?? {}),
2352
2473
  });
2353
2474
  }
2475
+ // Determine tool replay mode for model compatibility (e.g., polaris-alpha)
2476
+ const toolReplayMode = this.determineToolReplayMode(model);
2354
2477
  // Detect the tool format to use BEFORE building messages
2355
2478
  // This is needed so that Kimi K2 tool IDs can be generated in the correct format
2356
2479
  const detectedFormat = this.detectToolFormat();
@@ -2363,7 +2486,13 @@ export class OpenAIProvider extends BaseProvider {
2363
2486
  // Convert IContent to OpenAI messages format
2364
2487
  // Use buildMessagesWithReasoning for reasoning-aware message building
2365
2488
  // Pass detectedFormat so that Kimi K2 tool IDs are generated correctly
2366
- const messages = this.buildMessagesWithReasoning(contents, options, detectedFormat);
2489
+ const messages = toolReplayMode === 'native'
2490
+ ? this.buildMessagesWithReasoning(contents, options, detectedFormat)
2491
+ : this.convertToOpenAIMessages(contents, toolReplayMode, options.config ?? options.runtime?.config ?? this.globalConfig);
2492
+ // Log tool replay mode usage for debugging
2493
+ if (logger.enabled && toolReplayMode !== 'native') {
2494
+ logger.debug(() => `[OpenAIProvider] Using textual tool replay mode for model '${model}'`);
2495
+ }
2367
2496
  // Convert Gemini format tools to OpenAI format using the schema converter
2368
2497
  // This ensures required fields are always present in tool schemas
2369
2498
  let formattedTools = convertToolsToOpenAI(tools);
@@ -2514,11 +2643,22 @@ export class OpenAIProvider extends BaseProvider {
2514
2643
  // Bucket failover callback for 429 errors - tools mode
2515
2644
  // @plan PLAN-20251213issue686 Bucket failover integration for OpenAIProvider
2516
2645
  const onPersistent429CallbackTools = async () => {
2517
- const { result, client } = await this.handleBucketFailoverOnPersistent429(options, logger);
2518
- if (client) {
2519
- failoverClientTools = client;
2520
- }
2521
- return result;
2646
+ // Try to get the bucket failover handler from runtime context config
2647
+ const failoverHandler = options.runtime?.config?.getBucketFailoverHandler();
2648
+ if (failoverHandler && failoverHandler.isEnabled()) {
2649
+ logger.debug(() => 'Attempting bucket failover on persistent 429');
2650
+ const success = await failoverHandler.tryFailover();
2651
+ if (success) {
2652
+ // Rebuild client with fresh credentials from new bucket
2653
+ failoverClientTools = await this.getClient(options);
2654
+ logger.debug(() => `Bucket failover successful, new bucket: ${failoverHandler.getCurrentBucket()}`);
2655
+ return true; // Signal retry with new bucket
2656
+ }
2657
+ logger.debug(() => 'Bucket failover failed - no more buckets available');
2658
+ return false; // No more buckets, stop retrying
2659
+ }
2660
+ // No bucket failover configured
2661
+ return null;
2522
2662
  };
2523
2663
  if (streamingEnabled) {
2524
2664
  // Streaming mode - use retry loop with compression support
@@ -2567,7 +2707,7 @@ export class OpenAIProvider extends BaseProvider {
2567
2707
  // Tool message compression logic
2568
2708
  if (!compressedOnce &&
2569
2709
  this.shouldCompressToolMessages(error, logger) &&
2570
- this.compressToolMessages(requestBody.messages, 512, logger)) {
2710
+ this.compressToolMessages(requestBody.messages, MAX_TOOL_RESPONSE_RETRY_CHARS, logger)) {
2571
2711
  compressedOnce = true;
2572
2712
  logger.warn(() => `[OpenAIProvider] Retrying streaming request after compressing tool responses due to provider 400`);
2573
2713
  continue;
@@ -2650,7 +2790,7 @@ export class OpenAIProvider extends BaseProvider {
2650
2790
  // Tool message compression logic
2651
2791
  if (!compressedOnce &&
2652
2792
  this.shouldCompressToolMessages(error, logger) &&
2653
- this.compressToolMessages(requestBody.messages, 512, logger)) {
2793
+ this.compressToolMessages(requestBody.messages, MAX_TOOL_RESPONSE_RETRY_CHARS, logger)) {
2654
2794
  compressedOnce = true;
2655
2795
  logger.warn(() => `[OpenAIProvider] Retrying request after compressing tool responses due to provider 400`);
2656
2796
  continue;