praxis-agent 0.48.1 → 0.50.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/README.md CHANGED
@@ -212,11 +212,17 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
212
212
  imports, memory, skills, commands, agents, hooks, settings, MCP servers,
213
213
  plugins, and append-only `praxis.transcript` JSONL sessions under `~/.praxis`,
214
214
  with bounded MCP connection, discovery, and tool operations plus safe
215
- disconnect recovery that never replays an already-dispatched call.
215
+ disconnect recovery that never replays an already-dispatched call. Default
216
+ tool selection defers `mcp__*` schemas behind a turn-scoped `ToolSearch`;
217
+ each query activates at most eight deterministic matches for the next model
218
+ request. Explicit concrete `--tools` selections load selected tools directly, while
219
+ `--disallowedTools ToolSearch` restores the complete tool list.
216
220
  - **Provider-neutral models** — native Provider Registry/Vault routing, API
217
221
  adapters, an experimental Codex OAuth adapter, explicit capability checks,
218
- per-attempt bounded deadlines, typed recovery for malformed streamed tool
219
- arguments without tool execution or lost resumability, and
222
+ separate per-attempt connect, byte-idle, and absolute-total timeouts, typed
223
+ recovery for malformed streamed tool arguments without tool execution or
224
+ lost resumability, one default-on bounded Anthropic non-streaming replay for
225
+ eligible stream/idle failures without exposing failed-attempt output, and
220
226
  token-only/no-API-dollar accounting for subscription runs.
221
227
  - **Transactional self-update** — `praxis update` verifies the package before
222
228
  installing it, rejects concurrent updates, and can roll back after an
@@ -302,7 +308,7 @@ normal/low-capability full-frame p95 budgets of `<16.7/<33 ms`.
302
308
  `npm run test:coverage` measures all production code under `src/**` with V8 and
303
309
  enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines,
304
310
  and rejects any production runtime module with zero covered statements (while allowing
305
- type-only modules). `npm run test:fixtures` executes the 69-behavior native contract; 61 behaviors
311
+ type-only modules). `npm run test:fixtures` executes the 70-behavior native contract; 62 behaviors
306
312
  are qualified and 8 are explicitly excluded. `npm run verify:fixture-contracts`
307
313
  performs the structural check and is part of `npm run check`.
308
314
  `npm run test:core-completion` is retained as a compatibility alias for
@@ -39,6 +39,7 @@ export interface ClaudeSessionServiceOptions {
39
39
  claudeVersion: string;
40
40
  provider?: ModelProvider;
41
41
  tools?: ToolRegistry;
42
+ deferMcpTools?: boolean;
42
43
  permissions?: PermissionResolver;
43
44
  permissionResolverForMode?: (mode: AgentPermissionMode) => PermissionResolver;
44
45
  permissionMode?: ClaudePermissionMode;
@@ -49,6 +49,7 @@ import { ClaudeWorktreeToolRegistry } from '../tools/claude-worktree-tools.js';
49
49
  import { completeMeteredModelRequest } from './metered-model-completion.js';
50
50
  import { SessionMemoryController, SessionMemoryStateError, SessionMemoryStore, } from './session-memory.js';
51
51
  import { FilteredToolRegistry } from '../tools/filtered-tool-registry.js';
52
+ import { DeferredToolCatalog } from '../tools/deferred-tool-catalog.js';
52
53
  import { ClaudeCapabilityToolRegistry, resolveClaudeToolCapabilities, } from '../tools/claude-capabilities.js';
53
54
  import { generateToolUseSummary } from './tool-use-summary.js';
54
55
  import { ClaudeUserMessageToolRegistry, } from '../tools/claude-user-message.js';
@@ -1108,6 +1109,9 @@ export class ClaudeSessionService {
1108
1109
  ? { providerForModel: this.options.providerForModel }
1109
1110
  : {}),
1110
1111
  baseTools: wrappedBase,
1112
+ ...(this.options.deferMcpTools === undefined
1113
+ ? {}
1114
+ : { deferMcpTools: this.options.deferMcpTools }),
1111
1115
  permissions: this.options.permissions,
1112
1116
  ...(this.options.permissionResolverForMode
1113
1117
  ? {
@@ -2846,6 +2850,9 @@ export class ClaudeSessionService {
2846
2850
  ? { providerForModel: this.options.providerForModel }
2847
2851
  : {}),
2848
2852
  baseTools,
2853
+ ...(this.options.deferMcpTools === undefined
2854
+ ? {}
2855
+ : { deferMcpTools: this.options.deferMcpTools }),
2849
2856
  permissions: turnPermissions,
2850
2857
  ...(this.options.permissionResolverForMode
2851
2858
  ? {
@@ -3024,9 +3031,16 @@ export class ClaudeSessionService {
3024
3031
  const structuredTools = this.options.structuredOutputSchema && structuredCapture
3025
3032
  ? new StructuredOutputRegistry(agentScopedTools ?? this.options.tools ?? emptyToolRegistry, this.options.structuredOutputSchema, structuredCapture)
3026
3033
  : agentScopedTools;
3027
- const hookTools = this.options.hooks && structuredTools && turnPermissions
3034
+ const activeTurnTools = structuredTools
3035
+ ? new DeferredToolCatalog(structuredTools).startTurn({
3036
+ enabled: this.options.deferMcpTools !== false &&
3037
+ !(agent && agent.tools !== undefined),
3038
+ restoredToolNames: unresolvedToolCalls.map((call) => call.name),
3039
+ })
3040
+ : undefined;
3041
+ const hookTools = this.options.hooks && activeTurnTools && turnPermissions
3028
3042
  ? new ClaudeHookToolCoordinator({
3029
- tools: structuredTools,
3043
+ tools: activeTurnTools,
3030
3044
  permissions: turnPermissions,
3031
3045
  hooks: this.options.hooks,
3032
3046
  session: hookSession,
@@ -3063,7 +3077,7 @@ export class ClaudeSessionService {
3063
3077
  ...(hookTools
3064
3078
  ? { tools: hookTools, permissions: hookTools }
3065
3079
  : {
3066
- ...(structuredTools ? { tools: structuredTools } : {}),
3080
+ ...(activeTurnTools ? { tools: activeTurnTools } : {}),
3067
3081
  ...(turnPermissions ? { permissions: turnPermissions } : {}),
3068
3082
  }),
3069
3083
  });
@@ -3446,8 +3460,8 @@ export class ClaudeSessionService {
3446
3460
  let compactionDurationMs;
3447
3461
  let compactionDurationWithoutRetriesMs;
3448
3462
  let compactionModelUsage;
3449
- const definitions = provider.capabilities.tools
3450
- ? (structuredTools?.definitions() ?? [])
3463
+ const currentDefinitions = () => provider.capabilities.tools
3464
+ ? (activeTurnTools?.definitions() ?? [])
3451
3465
  : [];
3452
3466
  const budget = this.contextBudget(provider);
3453
3467
  const contextEngine = new ContextEngine({
@@ -3526,7 +3540,7 @@ export class ClaudeSessionService {
3526
3540
  ...pendingMessages,
3527
3541
  ]),
3528
3542
  ],
3529
- tools: definitions,
3543
+ tools: currentDefinitions(),
3530
3544
  };
3531
3545
  },
3532
3546
  irreducible: () => ({
@@ -3540,12 +3554,13 @@ export class ClaudeSessionService {
3540
3554
  })),
3541
3555
  ]),
3542
3556
  ],
3543
- tools: definitions,
3557
+ tools: currentDefinitions(),
3544
3558
  }),
3545
3559
  propose: async () => {
3546
3560
  const activeNativeLease = nativeLease;
3547
3561
  if (!budget)
3548
3562
  throw new Error('Context budget is unavailable');
3563
+ const definitions = currentDefinitions();
3549
3564
  const historyMessages = activeTurnMessages();
3550
3565
  if (historyMessages.length === 0)
3551
3566
  throw new Error('Cannot compact an empty native transcript');
@@ -3754,7 +3769,10 @@ export class ClaudeSessionService {
3754
3769
  ]),
3755
3770
  ];
3756
3771
  return {
3757
- envelope: { messages: proposedMessages, tools: definitions },
3772
+ envelope: {
3773
+ messages: proposedMessages,
3774
+ tools: definitions,
3775
+ },
3758
3776
  commit: async () => {
3759
3777
  if (signal?.aborted)
3760
3778
  throw new AgentRunCancelledError();
@@ -4062,6 +4080,7 @@ export class ClaudeSessionService {
4062
4080
  };
4063
4081
  }
4064
4082
  if (shellCommand === undefined && budget) {
4083
+ const definitions = currentDefinitions();
4065
4084
  await contextEngine.prepare(contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4066
4085
  budget.assertFits(budget.evaluate([
4067
4086
  ...contextMessages,
@@ -4387,6 +4406,7 @@ export class ClaudeSessionService {
4387
4406
  ...contextMessages,
4388
4407
  ...injectTurnContext(memorySnapshot),
4389
4408
  ];
4409
+ const definitions = currentDefinitions();
4390
4410
  const currentContextTokens = contextEngine.report({
4391
4411
  messages: providerVisibleMessages,
4392
4412
  tools: definitions,
@@ -59,6 +59,7 @@ export interface ClaudeSubagentExecutorOptions {
59
59
  claudeVersion: string;
60
60
  provider: ModelProvider;
61
61
  baseTools: ToolRegistry;
62
+ deferMcpTools?: boolean;
62
63
  permissions: PermissionResolver;
63
64
  permissionResolverForMode?: (mode: AgentPermissionMode) => PermissionResolver;
64
65
  parentPermissionMode?: () => AgentPermissionMode;
@@ -10,6 +10,7 @@ import { ContextBudget } from '../core/context-budget.js';
10
10
  import { AgentRuntime, } from '../core/runtime.js';
11
11
  import { composePermissionResolvers } from '../core/permission-policy.js';
12
12
  import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
13
+ import { DeferredToolCatalog } from '../tools/deferred-tool-catalog.js';
13
14
  import { BUILTIN_STATUSLINE_AGENT_PATH, } from '../extensions/claude-extensions.js';
14
15
  import { ClaudeHookToolCoordinator } from '../hooks/claude-hook-tools.js';
15
16
  import { InMemorySidechainStore } from '../persistence/in-memory-sidechain-store.js';
@@ -2059,9 +2060,14 @@ export class ClaudeSubagentExecutor {
2059
2060
  ? new RestrictedToolRegistry(structuredOnlyTools, ['Read', 'Edit'])
2060
2061
  : structuredOnlyTools, options.outputSchema, options.structuredOutput)
2061
2062
  : scopedTools;
2063
+ const activeAgentTools = new DeferredToolCatalog(agentTools).startTurn({
2064
+ enabled: this.options.deferMcpTools !== false &&
2065
+ customAgent?.tools === undefined,
2066
+ restoredToolNames: nativeRecoveryCalls.map((call) => call.name),
2067
+ });
2062
2068
  const runtimeTools = scopedHooks
2063
2069
  ? new ClaudeHookToolCoordinator({
2064
- tools: agentTools,
2070
+ tools: activeAgentTools,
2065
2071
  permissions,
2066
2072
  hooks: scopedHooks,
2067
2073
  session: hookSession,
@@ -2072,7 +2078,7 @@ export class ClaudeSubagentExecutor {
2072
2078
  }
2073
2079
  : {}),
2074
2080
  })
2075
- : agentTools;
2081
+ : activeAgentTools;
2076
2082
  const runtimePermissions = scopedHooks
2077
2083
  ? runtimeTools
2078
2084
  : permissions;
@@ -2241,9 +2247,7 @@ export class ClaudeSubagentExecutor {
2241
2247
  : { reserveTokens: this.options.contextReserveTokens }),
2242
2248
  })
2243
2249
  : null;
2244
- const definitions = options.provider.capabilities.tools
2245
- ? runtimeTools.definitions()
2246
- : [];
2250
+ const currentDefinitions = () => options.provider.capabilities.tools ? runtimeTools.definitions() : [];
2247
2251
  let observedMessages;
2248
2252
  let stableSystemMessageCount = 0;
2249
2253
  const assembleMessages = async () => {
@@ -2272,6 +2276,7 @@ export class ClaudeSubagentExecutor {
2272
2276
  ];
2273
2277
  observedMessages = messages;
2274
2278
  if (contextBudget) {
2279
+ const definitions = currentDefinitions();
2275
2280
  contextBudget.assertFits(contextBudget.evaluate(messages, definitions));
2276
2281
  }
2277
2282
  return messages;
@@ -2393,7 +2398,7 @@ export class ClaudeSubagentExecutor {
2393
2398
  ...(options.signal ? { signal: options.signal } : {}),
2394
2399
  };
2395
2400
  const result = await runtime.run(runtimeRequest);
2396
- contextBudget?.observeUsage(result.usage, observedMessages ?? [], definitions);
2401
+ contextBudget?.observeUsage(result.usage, observedMessages ?? [], currentDefinitions());
2397
2402
  this.options.eventSink?.({
2398
2403
  type: 'task-progress',
2399
2404
  taskId: options.agentId,
@@ -29,10 +29,13 @@ const WORKER_RUNTIME_ENVIRONMENT = [
29
29
  'PRAXIS_PROVIDER',
30
30
  'PRAXIS_PROVIDER_PROFILE',
31
31
  'PRAXIS_PROVIDER_DEADLINE_MS',
32
+ 'PRAXIS_PROVIDER_CONNECT_TIMEOUT_MS',
33
+ 'PRAXIS_PROVIDER_IDLE_TIMEOUT_MS',
32
34
  'PRAXIS_BASE_URL',
33
35
  'PRAXIS_MAX_OUTPUT_TOKENS',
34
36
  'PRAXIS_ANTHROPIC_VERSION',
35
37
  'PRAXIS_ANTHROPIC_WEB_SEARCH',
38
+ 'PRAXIS_DISABLE_NONSTREAMING_FALLBACK',
36
39
  'PRAXIS_ANTHROPIC_PROMPT_CACHING',
37
40
  'PRAXIS_ANTHROPIC_PROMPT_CACHE_TTL',
38
41
  'PRAXIS_CONTEXT_WINDOW_TOKENS',
@@ -24,6 +24,11 @@ import { executeProviderAuthCommand } from './cli/provider-auth-command.js';
24
24
  import { type PluginEvalDependencies } from './plugins/claude-plugin-eval.js';
25
25
  import { type ProjectEvalDependencies } from './evals/project-eval.js';
26
26
  import { type SelfUpdateResult } from './maintenance/self-update.js';
27
+ export declare function shouldDeferMcpTools(input: {
28
+ simpleMode: boolean;
29
+ tools?: readonly string[] | undefined;
30
+ disallowedTools: readonly string[];
31
+ }): boolean;
27
32
  export { parseContextEnvironment, parseProviderEnvironment };
28
33
  export interface CliIO {
29
34
  stdout(message: string | Uint8Array): void;
@@ -77,6 +77,16 @@ import { formatDoctorReport, runDoctor } from './maintenance/doctor.js';
77
77
  import { runSelfUpdate, } from './maintenance/self-update.js';
78
78
  import { executeClaudeProjectPurge, planClaudeProjectPurge, } from './application/claude-project-purge.js';
79
79
  const VERSION = createRequire(import.meta.url)('../package.json').version;
80
+ export function shouldDeferMcpTools(input) {
81
+ if (input.tools?.includes('ToolSearch')) {
82
+ throw new Error('Unknown tool in --tools: ToolSearch');
83
+ }
84
+ return (!input.simpleMode &&
85
+ !input.disallowedTools.includes('ToolSearch') &&
86
+ (input.tools === undefined ||
87
+ (input.tools.length > 0 &&
88
+ input.tools.every((tool) => tool === 'default'))));
89
+ }
80
90
  function fileResourceBaseUrl(environment, providerEnvironment) {
81
91
  return (environment.PRAXIS_FILES_BASE_URL ??
82
92
  providerEnvironment?.baseUrl ??
@@ -981,6 +991,11 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
981
991
  ? {}
982
992
  : { permissionMode: interactivePermissionMode }),
983
993
  }, cwd);
994
+ const deferMcpTools = shouldDeferMcpTools({
995
+ simpleMode,
996
+ tools: cli.tools,
997
+ disallowedTools: cli.disallowedTools,
998
+ });
984
999
  if (experimentalNativeTranscriptWrites) {
985
1000
  const incompatible = [
986
1001
  ['sessionPersistence', cli.sessionPersistence === false],
@@ -1208,6 +1223,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1208
1223
  pricing: ModelPricingRegistry.fromEnvironment(runtimeEnvironment.PRAXIS_PRICING_JSON),
1209
1224
  }),
1210
1225
  collectMetrics: true,
1226
+ deferMcpTools,
1211
1227
  ...(!experimentalNativeTranscriptWrites && sessionMemoryProviderFactory
1212
1228
  ? { sessionMemoryProviderFactory }
1213
1229
  : {}),
@@ -537,14 +537,17 @@ export declare class ModelProviderError extends Error {
537
537
  readonly status?: number;
538
538
  readonly retryDelayMs?: number;
539
539
  readonly kind?: ProviderErrorKind;
540
+ readonly timeoutPhase?: ProviderTimeoutPhase;
540
541
  constructor(message: string, options: {
541
542
  retryable: boolean;
542
543
  kind?: ProviderErrorKind;
543
544
  status?: number;
544
545
  retryDelayMs?: number;
546
+ timeoutPhase?: ProviderTimeoutPhase;
545
547
  cause?: unknown;
546
548
  });
547
549
  }
550
+ export type ProviderTimeoutPhase = 'connect' | 'idle' | 'total';
548
551
  export declare class AgentRunCancelledError extends Error {
549
552
  readonly name = "AgentRunCancelledError";
550
553
  constructor();
@@ -35,6 +35,7 @@ export class ModelProviderError extends Error {
35
35
  status;
36
36
  retryDelayMs;
37
37
  kind;
38
+ timeoutPhase;
38
39
  constructor(message, options) {
39
40
  super(message, options.cause === undefined ? undefined : { cause: options.cause });
40
41
  this.retryable = options.retryable;
@@ -44,6 +45,8 @@ export class ModelProviderError extends Error {
44
45
  this.status = options.status;
45
46
  if (options.retryDelayMs !== undefined)
46
47
  this.retryDelayMs = options.retryDelayMs;
48
+ if (options.timeoutPhase !== undefined)
49
+ this.timeoutPhase = options.timeoutPhase;
47
50
  }
48
51
  }
49
52
  export class AgentRunCancelledError extends Error {
@@ -334,9 +337,6 @@ export class AgentRuntime {
334
337
  let linesRemoved = 0;
335
338
  let activeAttemptHasPresentation = false;
336
339
  let activeAttemptDiscarded = false;
337
- const definitions = this.provider.capabilities.tools
338
- ? (this.options.tools?.definitions() ?? [])
339
- : [];
340
340
  const maxModelTurns = request.maxModelTurns ?? this.options.maxModelTurns;
341
341
  if (maxModelTurns !== undefined &&
342
342
  (!Number.isSafeInteger(maxModelTurns) || maxModelTurns <= 0)) {
@@ -411,6 +411,9 @@ export class AgentRuntime {
411
411
  stableSystemMessageCount: request.stableSystemMessageCount,
412
412
  }),
413
413
  };
414
+ const definitions = this.provider.capabilities.tools
415
+ ? (this.options.tools?.definitions() ?? [])
416
+ : [];
414
417
  if (definitions.length > 0)
415
418
  providerRequest.tools = definitions;
416
419
  if (request.signal)
@@ -20,9 +20,11 @@ export declare class ClaudeHookToolCoordinator implements ToolRegistry, Permissi
20
20
  private readonly prepared;
21
21
  constructor(options: ClaudeHookToolCoordinatorOptions);
22
22
  definitions(): readonly import("../core/runtime.js").ModelToolDefinition[];
23
- schedulingPolicy(): {
23
+ schedulingPolicy(call: ModelToolCall): {
24
24
  concurrency: "exclusive";
25
25
  startAfterAssistant: boolean;
26
+ abortGroupOnError?: boolean;
27
+ cancelOnInterrupt?: boolean;
26
28
  };
27
29
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
28
30
  resolve(call: ModelToolCall, context?: PermissionResolutionContext): Promise<PermissionDecision>;
@@ -8,8 +8,15 @@ export class ClaudeHookToolCoordinator {
8
8
  definitions() {
9
9
  return this.options.tools.definitions();
10
10
  }
11
- schedulingPolicy() {
11
+ schedulingPolicy(call) {
12
+ const wrapped = this.options.tools.schedulingPolicy?.(call);
12
13
  return {
14
+ ...(wrapped?.cancelOnInterrupt === undefined
15
+ ? {}
16
+ : { cancelOnInterrupt: wrapped.cancelOnInterrupt }),
17
+ ...(wrapped?.abortGroupOnError === undefined
18
+ ? {}
19
+ : { abortGroupOnError: wrapped.abortGroupOnError }),
13
20
  concurrency: 'exclusive',
14
21
  startAfterAssistant: true,
15
22
  };
@@ -12,6 +12,7 @@ import { validateSedSafety } from './sed-safety.js';
12
12
  import { parseShellRule, shellRuleMatches } from './shell-rule-matching.js';
13
13
  import { effectiveAdditionalDirectories, effectivePermissionMode, filePermissionSuggestions, permissionRuleValueFromString, permissionRuleStringIsValid, permissionRuleValueToString, shellInputIsReadOnly, shellPermissionSuggestions, shellSubcommands, skillPermissionSuggestions, } from './permission-updates.js';
14
14
  const DEFAULT_BEHAVIOR = {
15
+ ToolSearch: 'allow',
15
16
  Agent: 'allow',
16
17
  SendMessage: 'allow',
17
18
  SendUserMessage: 'allow',
@@ -16,6 +16,7 @@ export interface AnthropicCompatibleProviderOptions {
16
16
  maxToolMetadataBytes?: number;
17
17
  maxErrorBodyBytes?: number;
18
18
  fetchImplementation?: typeof fetch;
19
+ streaming?: boolean;
19
20
  }
20
21
  export declare class AnthropicCompatibleProvider implements ModelProvider {
21
22
  private readonly options;
@@ -32,6 +33,7 @@ export declare class AnthropicCompatibleProvider implements ModelProvider {
32
33
  private readonly maxErrorBodyBytes;
33
34
  private readonly thinking;
34
35
  private readonly promptCaching;
36
+ private readonly streaming;
35
37
  constructor(options: AnthropicCompatibleProviderOptions);
36
38
  complete(request: ModelRequest): AsyncIterable<ModelStreamEvent>;
37
39
  }
@@ -1,9 +1,20 @@
1
1
  import { ModelProviderError, malformedModelToolCall, } from '../core/runtime.js';
2
2
  import { transportFailureKind } from './provider-errors.js';
3
+ import { reportProviderTransportActivity } from './provider-transport-activity.js';
4
+ import { markNonStreamingFallbackEligible } from './non-streaming-fallback-provider.js';
3
5
  import { createAnthropicPromptCachePolicyResolver, } from './anthropic-prompt-cache.js';
4
6
  function isRecord(value) {
5
7
  return typeof value === 'object' && value !== null && !Array.isArray(value);
6
8
  }
9
+ function readNonNegativeTokenCount(usage, field, required) {
10
+ const value = usage[field];
11
+ if (value === undefined && !required)
12
+ return undefined;
13
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
14
+ throw new ModelProviderError(`Provider returned an invalid ${field} counter`, { retryable: false });
15
+ }
16
+ return value;
17
+ }
7
18
  function webSearchLinks(value) {
8
19
  if (!Array.isArray(value))
9
20
  return [];
@@ -633,6 +644,7 @@ export class AnthropicCompatibleProvider {
633
644
  maxErrorBodyBytes;
634
645
  thinking;
635
646
  promptCaching;
647
+ streaming;
636
648
  constructor(options) {
637
649
  this.options = options;
638
650
  if (options.contextWindowTokens !== undefined) {
@@ -640,6 +652,7 @@ export class AnthropicCompatibleProvider {
640
652
  }
641
653
  this.endpoint = `${options.baseUrl.replace(/\/+$/, '')}/messages`;
642
654
  this.model = options.model;
655
+ this.streaming = options.streaming ?? true;
643
656
  this.fetchImplementation = options.fetchImplementation ?? fetch;
644
657
  this.maxOutputTokens = positiveInteger(options.maxOutputTokens ??
645
658
  (options.model.includes('claude-opus-4-6')
@@ -648,7 +661,7 @@ export class AnthropicCompatibleProvider {
648
661
  ? 32_000
649
662
  : 8192), 'Max output tokens');
650
663
  this.capabilities = {
651
- streaming: true,
664
+ streaming: this.streaming,
652
665
  usage: true,
653
666
  tools: true,
654
667
  images: true,
@@ -720,7 +733,7 @@ export class AnthropicCompatibleProvider {
720
733
  model: this.options.model,
721
734
  max_tokens: maxTokens,
722
735
  messages: serialized.messages,
723
- stream: true,
736
+ stream: this.streaming,
724
737
  ...(thinkingPayload ? { thinking: thinkingPayload } : {}),
725
738
  ...(request.effort
726
739
  ? { output_config: { effort: request.effort } }
@@ -761,7 +774,9 @@ export class AnthropicCompatibleProvider {
761
774
  requestInit.signal = request.signal;
762
775
  let response;
763
776
  try {
777
+ reportProviderTransportActivity(request, 'request-started');
764
778
  response = await this.fetchImplementation(this.endpoint, requestInit);
779
+ reportProviderTransportActivity(request, 'response-received');
765
780
  }
766
781
  catch (error) {
767
782
  const kind = transportFailureKind(error, request.signal);
@@ -792,6 +807,8 @@ export class AnthropicCompatibleProvider {
792
807
  ended = true;
793
808
  break;
794
809
  }
810
+ if (value.byteLength > 0)
811
+ reportProviderTransportActivity(request, 'response-chunk');
795
812
  size += value.byteLength;
796
813
  if (size > this.maxErrorBodyBytes) {
797
814
  throw new ModelProviderError(`Provider error response exceeded ${this.maxErrorBodyBytes} bytes`, { retryable: false, status: response.status });
@@ -824,11 +841,144 @@ export class AnthropicCompatibleProvider {
824
841
  status: response.status,
825
842
  });
826
843
  }
844
+ if (!this.streaming) {
845
+ if (!response.body) {
846
+ throw new ModelProviderError('Provider response has no body', {
847
+ kind: 'transport_error',
848
+ retryable: true,
849
+ });
850
+ }
851
+ const reader = response.body.getReader();
852
+ const chunks = [];
853
+ let size = 0;
854
+ let ended = false;
855
+ try {
856
+ while (true) {
857
+ const { done, value } = await reader.read();
858
+ if (done) {
859
+ ended = true;
860
+ break;
861
+ }
862
+ if (value.byteLength > 0)
863
+ reportProviderTransportActivity(request, 'response-chunk');
864
+ size += value.byteLength;
865
+ if (size > this.maxStreamBufferBytes) {
866
+ throw new ModelProviderError(`Provider stream buffer exceeded ${this.maxStreamBufferBytes} bytes`, { retryable: false });
867
+ }
868
+ if (value.byteLength > 0)
869
+ chunks.push(value);
870
+ }
871
+ let payload;
872
+ try {
873
+ payload = JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8'));
874
+ }
875
+ catch (error) {
876
+ throw new ModelProviderError('Provider returned malformed JSON', {
877
+ retryable: false,
878
+ cause: error,
879
+ });
880
+ }
881
+ if (!isRecord(payload) ||
882
+ payload.type !== 'message' ||
883
+ payload.role !== 'assistant' ||
884
+ !Array.isArray(payload.content) ||
885
+ !isRecord(payload.usage)) {
886
+ throw new ModelProviderError('Provider returned an invalid message', {
887
+ retryable: false,
888
+ });
889
+ }
890
+ const state = {
891
+ blocks: new Map(),
892
+ thinking: new Map(),
893
+ tools: new Map(),
894
+ toolCallsSeen: 0,
895
+ metadataBytes: 0,
896
+ inputTokens: 0,
897
+ cacheReadInputTokens: 0,
898
+ cacheCreationInputTokens: 0,
899
+ outputTokens: 0,
900
+ webSearchRequests: 0,
901
+ usageSeen: false,
902
+ messageStarted: false,
903
+ messageDeltaSeen: false,
904
+ terminal: false,
905
+ };
906
+ const inputTokens = readNonNegativeTokenCount(payload.usage, 'input_tokens', true);
907
+ const outputTokens = readNonNegativeTokenCount(payload.usage, 'output_tokens', true);
908
+ const cacheReadInputTokens = readNonNegativeTokenCount(payload.usage, 'cache_read_input_tokens', false);
909
+ const cacheCreationInputTokens = readNonNegativeTokenCount(payload.usage, 'cache_creation_input_tokens', false);
910
+ const usage = {
911
+ input_tokens: inputTokens,
912
+ output_tokens: outputTokens,
913
+ ...(cacheReadInputTokens === undefined
914
+ ? {}
915
+ : { cache_read_input_tokens: cacheReadInputTokens }),
916
+ ...(cacheCreationInputTokens === undefined
917
+ ? {}
918
+ : { cache_creation_input_tokens: cacheCreationInputTokens }),
919
+ ...(payload.usage.server_tool_use === undefined
920
+ ? {}
921
+ : { server_tool_use: payload.usage.server_tool_use }),
922
+ };
923
+ const synthetic = [
924
+ {
925
+ type: 'message_start',
926
+ message: { usage },
927
+ },
928
+ ...payload.content.flatMap((block, index) => {
929
+ if (!isRecord(block) || typeof block.type !== 'string') {
930
+ throw new ModelProviderError('Provider returned an invalid content block', { retryable: false });
931
+ }
932
+ return [
933
+ { type: 'content_block_start', index, content_block: block },
934
+ { type: 'content_block_stop', index },
935
+ ];
936
+ }),
937
+ {
938
+ type: 'message_delta',
939
+ delta: { stop_reason: payload.stop_reason },
940
+ usage: {
941
+ output_tokens: outputTokens,
942
+ ...(usage.server_tool_use === undefined
943
+ ? {}
944
+ : { server_tool_use: usage.server_tool_use }),
945
+ },
946
+ },
947
+ { type: 'message_stop' },
948
+ ];
949
+ for (const event of synthetic) {
950
+ for (const normalized of parseSseEvent(JSON.stringify(event), state, this.maxToolArgumentsBytes, this.maxToolCallsPerResponse, this.maxToolMetadataBytes))
951
+ yield normalized;
952
+ }
953
+ }
954
+ catch (error) {
955
+ if (error instanceof ModelProviderError)
956
+ throw error;
957
+ const kind = transportFailureKind(error, request.signal);
958
+ throw new ModelProviderError('Provider response failed', {
959
+ kind,
960
+ retryable: kind === 'timeout' || kind === 'transport_error',
961
+ cause: error,
962
+ });
963
+ }
964
+ finally {
965
+ if (!ended) {
966
+ try {
967
+ await reader.cancel();
968
+ }
969
+ catch {
970
+ // Preserve the primary provider error.
971
+ }
972
+ }
973
+ reader.releaseLock();
974
+ }
975
+ return;
976
+ }
827
977
  if (!response.body) {
828
- throw new ModelProviderError('Provider response has no body', {
978
+ throw markNonStreamingFallbackEligible(new ModelProviderError('Provider response has no body', {
829
979
  kind: 'transport_error',
830
980
  retryable: true,
831
- });
981
+ }));
832
982
  }
833
983
  const reader = response.body.getReader();
834
984
  const decoder = new TextDecoder();
@@ -853,6 +1003,8 @@ export class AnthropicCompatibleProvider {
853
1003
  try {
854
1004
  stream: while (true) {
855
1005
  const { done, value } = await reader.read();
1006
+ if (!done && value.byteLength > 0)
1007
+ reportProviderTransportActivity(request, 'response-chunk');
856
1008
  buffer += decoder.decode(value, { stream: !done });
857
1009
  buffer = buffer.replaceAll('\r\n', '\n');
858
1010
  if (Buffer.byteLength(buffer) > this.maxStreamBufferBytes) {
@@ -882,18 +1034,21 @@ export class AnthropicCompatibleProvider {
882
1034
  }
883
1035
  }
884
1036
  if (!state.terminal) {
885
- throw new ModelProviderError('Provider stream ended before a terminal event', { retryable: true });
1037
+ throw markNonStreamingFallbackEligible(new ModelProviderError('Provider stream ended before a terminal event', { retryable: true }));
886
1038
  }
887
1039
  }
888
1040
  catch (error) {
889
1041
  if (error instanceof ModelProviderError)
890
1042
  throw error;
891
1043
  const kind = transportFailureKind(error, request.signal);
892
- throw new ModelProviderError('Provider stream failed', {
1044
+ const streamError = new ModelProviderError('Provider stream failed', {
893
1045
  kind,
894
1046
  retryable: kind === 'timeout' || kind === 'transport_error',
895
1047
  cause: error,
896
1048
  });
1049
+ throw kind === 'timeout' || kind === 'transport_error'
1050
+ ? markNonStreamingFallbackEligible(streamError)
1051
+ : streamError;
897
1052
  }
898
1053
  finally {
899
1054
  if (!streamEnded) {