praxis-agent 0.48.1 → 0.49.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
@@ -215,8 +215,10 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
215
215
  disconnect recovery that never replays an already-dispatched call.
216
216
  - **Provider-neutral models** — native Provider Registry/Vault routing, API
217
217
  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
218
+ separate per-attempt connect, byte-idle, and absolute-total timeouts, typed
219
+ recovery for malformed streamed tool arguments without tool execution or
220
+ lost resumability, one default-on bounded Anthropic non-streaming replay for
221
+ eligible stream/idle failures without exposing failed-attempt output, and
220
222
  token-only/no-API-dollar accounting for subscription runs.
221
223
  - **Transactional self-update** — `praxis update` verifies the package before
222
224
  installing it, rejects concurrent updates, and can roll back after an
@@ -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',
@@ -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 {
@@ -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) {
@@ -1,5 +1,6 @@
1
1
  import { ModelProviderError, malformedModelToolCall, } from '../core/runtime.js';
2
2
  import { CodexOAuthError, } from './codex-oauth.js';
3
+ import { reportProviderTransportActivity } from './provider-transport-activity.js';
3
4
  export const CODEX_RESPONSES_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses';
4
5
  const DEFAULT_MAX_STREAM = 1024 * 1024;
5
6
  const DEFAULT_MAX_ARGUMENTS = 1024 * 1024;
@@ -657,7 +658,7 @@ export function parseCodexSseFrame(data) {
657
658
  reasoning: DEFAULT_MAX_REASONING,
658
659
  });
659
660
  }
660
- async function drainBody(response, maxBytes) {
661
+ async function drainBody(response, maxBytes, request) {
661
662
  const reader = response.body?.getReader();
662
663
  if (!reader)
663
664
  return;
@@ -670,6 +671,8 @@ async function drainBody(response, maxBytes) {
670
671
  ended = true;
671
672
  return;
672
673
  }
674
+ if (next.value.byteLength > 0)
675
+ reportProviderTransportActivity(request, 'response-chunk');
673
676
  total += next.value.byteLength;
674
677
  if (total > maxBytes)
675
678
  return;
@@ -741,12 +744,14 @@ export class CodexSubscriptionProvider {
741
744
  'content-type': 'application/json',
742
745
  };
743
746
  try {
747
+ reportProviderTransportActivity(request, 'request-started');
744
748
  response = await this.fetchImplementation(CODEX_RESPONSES_ENDPOINT, {
745
749
  method: 'POST',
746
750
  headers,
747
751
  body: JSON.stringify(body),
748
752
  ...(signal ? { signal } : {}),
749
753
  });
754
+ reportProviderTransportActivity(request, 'response-received');
750
755
  }
751
756
  catch (error) {
752
757
  throw transportError(error, signal);
@@ -754,12 +759,13 @@ export class CodexSubscriptionProvider {
754
759
  if (response.status === 401 && !refreshed) {
755
760
  refreshed = true;
756
761
  try {
757
- await drainBody(response, this.maxErrorBodyBytes);
762
+ await drainBody(response, this.maxErrorBodyBytes, request);
758
763
  }
759
764
  catch {
760
765
  /* ignore unread body failures */
761
766
  }
762
767
  try {
768
+ reportProviderTransportActivity(request, 'request-started');
763
769
  access = await this.options.access({
764
770
  forceAfter: access.accessToken,
765
771
  ...(signal === undefined ? {} : { signal }),
@@ -772,7 +778,7 @@ export class CodexSubscriptionProvider {
772
778
  }
773
779
  if (!response.ok) {
774
780
  try {
775
- await drainBody(response, this.maxErrorBodyBytes);
781
+ await drainBody(response, this.maxErrorBodyBytes, request);
776
782
  }
777
783
  catch {
778
784
  /* preserve generic error */
@@ -809,6 +815,8 @@ export class CodexSubscriptionProvider {
809
815
  try {
810
816
  while (true) {
811
817
  const next = await reader.read();
818
+ if (!next.done && next.value.byteLength > 0)
819
+ reportProviderTransportActivity(request, 'response-chunk');
812
820
  buffer += decoder.decode(next.value, { stream: !next.done });
813
821
  buffer = buffer.replaceAll('\r\n', '\n');
814
822
  if (bytes(buffer) > this.maxStreamBufferBytes)
@@ -1,13 +1,21 @@
1
1
  import { type ModelProvider, type ModelRequest, type ModelStreamEvent } from '../core/runtime.js';
2
2
  export declare const DEFAULT_PROVIDER_DEADLINE_MS = 90000;
3
+ export declare const DEFAULT_PROVIDER_CONNECT_TIMEOUT_MS = 90000;
4
+ export declare const DEFAULT_PROVIDER_IDLE_TIMEOUT_MS = 90000;
3
5
  export interface DeadlineModelProviderOptions {
4
6
  provider: ModelProvider;
5
7
  deadlineMs?: number;
8
+ connectTimeoutMs?: number;
9
+ idleTimeoutMs?: number;
6
10
  }
7
11
  /** Bounds each streamed completion while preserving the wrapped provider's API. */
8
12
  export declare class DeadlineModelProvider implements ModelProvider {
9
13
  private readonly provider;
10
14
  private readonly deadlineMs;
15
+ private readonly connectTimeoutMs;
16
+ private readonly idleTimeoutMs;
17
+ private readonly connectTimeoutExplicit;
18
+ private readonly idleTimeoutExplicit;
11
19
  readonly model?: string;
12
20
  constructor(options: DeadlineModelProviderOptions);
13
21
  get capabilities(): ModelProvider['capabilities'];
@@ -1,5 +1,8 @@
1
1
  import { ModelProviderError, } from '../core/runtime.js';
2
+ import { detachProviderTransportActivity, observeProviderTransportActivity, } from './provider-transport-activity.js';
2
3
  export const DEFAULT_PROVIDER_DEADLINE_MS = 90_000;
4
+ export const DEFAULT_PROVIDER_CONNECT_TIMEOUT_MS = 90_000;
5
+ export const DEFAULT_PROVIDER_IDLE_TIMEOUT_MS = 90_000;
3
6
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
4
7
  function deferred() {
5
8
  let resolve;
@@ -12,6 +15,10 @@ function deferred() {
12
15
  export class DeadlineModelProvider {
13
16
  provider;
14
17
  deadlineMs;
18
+ connectTimeoutMs;
19
+ idleTimeoutMs;
20
+ connectTimeoutExplicit;
21
+ idleTimeoutExplicit;
15
22
  constructor(options) {
16
23
  const deadlineMs = options.deadlineMs === undefined
17
24
  ? DEFAULT_PROVIDER_DEADLINE_MS
@@ -21,8 +28,22 @@ export class DeadlineModelProvider {
21
28
  deadlineMs <= 0) {
22
29
  throw new Error('Provider deadline must be a positive integer');
23
30
  }
31
+ const connectTimeoutMs = options.connectTimeoutMs ?? deadlineMs;
32
+ if (!Number.isFinite(connectTimeoutMs) ||
33
+ !Number.isInteger(connectTimeoutMs) ||
34
+ connectTimeoutMs <= 0)
35
+ throw new Error('Provider connect timeout must be a positive integer');
36
+ const idleTimeoutMs = options.idleTimeoutMs ?? deadlineMs;
37
+ if (!Number.isFinite(idleTimeoutMs) ||
38
+ !Number.isInteger(idleTimeoutMs) ||
39
+ idleTimeoutMs <= 0)
40
+ throw new Error('Provider idle timeout must be a positive integer');
24
41
  this.provider = options.provider;
25
42
  this.deadlineMs = deadlineMs;
43
+ this.connectTimeoutMs = connectTimeoutMs;
44
+ this.idleTimeoutMs = idleTimeoutMs;
45
+ this.connectTimeoutExplicit = options.connectTimeoutMs !== undefined;
46
+ this.idleTimeoutExplicit = options.idleTimeoutMs !== undefined;
26
47
  Object.defineProperty(this, 'model', {
27
48
  configurable: false,
28
49
  enumerable: true,
@@ -40,15 +61,30 @@ export class DeadlineModelProvider {
40
61
  let callerSignal;
41
62
  let timer;
42
63
  let deadlineStartedAt;
64
+ let totalDueAt;
65
+ let phaseDueAt;
66
+ let phase = 'connect';
67
+ let pullPending = false;
68
+ let hasPulled = false;
69
+ let instrumentedRequest;
43
70
  let underlying;
44
71
  let cleanupStarted = false;
45
72
  const interruptionDeferred = deferred();
46
73
  const interruptionError = () => {
47
74
  if (interruption?.kind === 'timeout') {
48
- return new ModelProviderError('Provider request timed out', {
75
+ const timeoutPhase = interruption.cause instanceof TimeoutCause
76
+ ? interruption.cause.phase
77
+ : undefined;
78
+ const message = timeoutPhase === 'connect'
79
+ ? 'Provider connection timed out'
80
+ : timeoutPhase === 'idle'
81
+ ? 'Provider stream idle timed out'
82
+ : 'Provider request timed out';
83
+ return new ModelProviderError(message, {
49
84
  kind: 'timeout',
50
85
  retryable: true,
51
86
  cause: interruption.cause,
87
+ ...(timeoutPhase === undefined ? {} : { timeoutPhase }),
52
88
  });
53
89
  }
54
90
  return new ModelProviderError('Provider request cancelled', {
@@ -86,6 +122,8 @@ export class DeadlineModelProvider {
86
122
  clearTimeout(timer);
87
123
  timer = undefined;
88
124
  callerSignal?.removeEventListener('abort', onCallerAbort);
125
+ if (instrumentedRequest)
126
+ detachProviderTransportActivity(instrumentedRequest);
89
127
  };
90
128
  const finish = (outcome) => {
91
129
  if (interruption !== undefined)
@@ -99,19 +137,54 @@ export class DeadlineModelProvider {
99
137
  bestEffortUnderlyingReturn();
100
138
  };
101
139
  const onCallerAbort = () => finish({ kind: 'cancelled', cause: callerSignal?.reason });
102
- const timeoutCause = new Error('Provider request timed out');
103
- timeoutCause.name = 'TimeoutError';
140
+ class TimeoutCause extends Error {
141
+ phase;
142
+ constructor(timeoutPhase) {
143
+ super('Provider request timed out');
144
+ this.name = 'TimeoutError';
145
+ this.phase = timeoutPhase;
146
+ }
147
+ }
104
148
  const scheduleDeadline = () => {
105
149
  if (deadlineStartedAt === undefined || closed)
106
150
  return;
107
- const elapsedMs = Math.max(0, performance.now() - deadlineStartedAt);
108
- const remainingMs = this.deadlineMs - elapsedMs;
151
+ if (timer !== undefined)
152
+ clearTimeout(timer);
153
+ const now = performance.now();
154
+ const remainingTotal = (totalDueAt ?? now) - now;
155
+ const remainingPhase = pullPending
156
+ ? (phaseDueAt ?? now) - now
157
+ : Number.POSITIVE_INFINITY;
158
+ const remainingMs = Math.min(remainingTotal, remainingPhase);
109
159
  if (remainingMs <= 0) {
110
- finish({ kind: 'timeout', cause: timeoutCause });
160
+ const phaseTimeoutExplicit = phase === 'connect'
161
+ ? this.connectTimeoutExplicit
162
+ : this.idleTimeoutExplicit;
163
+ finish({
164
+ kind: 'timeout',
165
+ cause: new TimeoutCause(remainingPhase < remainingTotal ||
166
+ (remainingPhase <= remainingTotal && phaseTimeoutExplicit)
167
+ ? phase
168
+ : 'total'),
169
+ });
111
170
  return;
112
171
  }
113
172
  timer = setTimeout(scheduleDeadline, Math.min(remainingMs, MAX_TIMER_DELAY_MS));
114
173
  };
174
+ const onTransportActivity = (reported) => {
175
+ if (closed || deadlineStartedAt === undefined)
176
+ return;
177
+ const now = performance.now();
178
+ if (reported === 'request-started') {
179
+ phase = 'connect';
180
+ phaseDueAt = now + this.connectTimeoutMs;
181
+ }
182
+ else {
183
+ phase = 'idle';
184
+ phaseDueAt = now + this.idleTimeoutMs;
185
+ }
186
+ scheduleDeadline();
187
+ };
115
188
  const start = () => {
116
189
  if (started || closed)
117
190
  return;
@@ -125,11 +198,12 @@ export class DeadlineModelProvider {
125
198
  if (interruption !== undefined)
126
199
  return;
127
200
  deadlineStartedAt = performance.now();
201
+ totalDueAt = deadlineStartedAt + this.deadlineMs;
202
+ phaseDueAt = deadlineStartedAt + this.connectTimeoutMs;
128
203
  scheduleDeadline();
129
- const iterable = this.provider.complete({
130
- ...request,
131
- signal: controller.signal,
132
- });
204
+ instrumentedRequest = { ...request, signal: controller.signal };
205
+ observeProviderTransportActivity(instrumentedRequest, onTransportActivity);
206
+ const iterable = this.provider.complete(instrumentedRequest);
133
207
  underlying = iterable[Symbol.asyncIterator]();
134
208
  if (interruption !== undefined)
135
209
  bestEffortUnderlyingReturn();
@@ -153,11 +227,23 @@ export class DeadlineModelProvider {
153
227
  if (closed || !underlying)
154
228
  return { done: true, value: undefined };
155
229
  let nextPromise;
230
+ pullPending = true;
231
+ if (hasPulled) {
232
+ phaseDueAt =
233
+ performance.now() +
234
+ (phase === 'connect' ? this.connectTimeoutMs : this.idleTimeoutMs);
235
+ }
236
+ hasPulled = true;
237
+ scheduleDeadline();
238
+ if (interruption !== undefined)
239
+ throw interruptionError();
156
240
  try {
157
241
  const result = underlying.next();
158
242
  nextPromise = Promise.resolve(result);
159
243
  }
160
244
  catch (error) {
245
+ pullPending = false;
246
+ scheduleDeadline();
161
247
  if (interruption !== undefined)
162
248
  throw interruptionError();
163
249
  closed = true;
@@ -176,6 +262,8 @@ export class DeadlineModelProvider {
176
262
  outcome,
177
263
  })),
178
264
  ]);
265
+ pullPending = false;
266
+ scheduleDeadline();
179
267
  const observedInterruption = interruption ??
180
268
  (result.type === 'interruption' ? result.outcome : undefined);
181
269
  if (observedInterruption !== undefined) {
@@ -200,6 +288,10 @@ export class DeadlineModelProvider {
200
288
  closed = true;
201
289
  clearResources();
202
290
  }
291
+ else {
292
+ phase = 'idle';
293
+ phaseDueAt = performance.now() + this.idleTimeoutMs;
294
+ }
203
295
  return result.value;
204
296
  };
205
297
  const returnFromConsumer = async () => {
@@ -2,9 +2,12 @@ export interface ProviderEnvironment {
2
2
  provider: 'openai' | 'anthropic';
3
3
  baseUrl: string;
4
4
  deadlineMs: number;
5
+ connectTimeoutMs: number;
6
+ idleTimeoutMs: number;
5
7
  maxOutputTokens?: number;
6
8
  anthropicVersion?: string;
7
9
  webSearch?: boolean;
10
+ disableNonStreamingFallback: boolean;
8
11
  }
9
12
  export interface ContextEnvironment {
10
13
  contextWindowTokens?: number;
@@ -1,5 +1,5 @@
1
1
  import { createAnthropicPromptCachePolicyResolver } from './anthropic-prompt-cache.js';
2
- import { DEFAULT_PROVIDER_DEADLINE_MS } from './deadline-provider.js';
2
+ import { DEFAULT_PROVIDER_CONNECT_TIMEOUT_MS, DEFAULT_PROVIDER_DEADLINE_MS, DEFAULT_PROVIDER_IDLE_TIMEOUT_MS, } from './deadline-provider.js';
3
3
  export function parseProviderEnvironment(environment) {
4
4
  const provider = environment.PRAXIS_PROVIDER ?? 'openai';
5
5
  if (provider !== 'openai' && provider !== 'anthropic') {
@@ -15,6 +15,17 @@ export function parseProviderEnvironment(environment) {
15
15
  const deadlineMs = rawDeadlineMs === undefined
16
16
  ? DEFAULT_PROVIDER_DEADLINE_MS
17
17
  : Number(rawDeadlineMs);
18
+ const parseTimeout = (name, fallback) => {
19
+ const raw = environment[name];
20
+ if (raw !== undefined &&
21
+ (!/^\d+$/.test(raw) ||
22
+ Number(raw) <= 0 ||
23
+ !Number.isSafeInteger(Number(raw))))
24
+ throw new Error(`${name} must be a positive safe integer`);
25
+ return raw === undefined ? fallback : Number(raw);
26
+ };
27
+ const connectTimeoutMs = parseTimeout('PRAXIS_PROVIDER_CONNECT_TIMEOUT_MS', DEFAULT_PROVIDER_CONNECT_TIMEOUT_MS);
28
+ const idleTimeoutMs = parseTimeout('PRAXIS_PROVIDER_IDLE_TIMEOUT_MS', DEFAULT_PROVIDER_IDLE_TIMEOUT_MS);
18
29
  const maxOutputTokens = environment.PRAXIS_MAX_OUTPUT_TOKENS;
19
30
  if (maxOutputTokens !== undefined &&
20
31
  (!/^\d+$/.test(maxOutputTokens) ||
@@ -38,6 +49,12 @@ export function parseProviderEnvironment(environment) {
38
49
  webSearch !== 'false') {
39
50
  throw new Error('PRAXIS_ANTHROPIC_WEB_SEARCH must be true or false');
40
51
  }
52
+ const disableFallback = environment.PRAXIS_DISABLE_NONSTREAMING_FALLBACK;
53
+ if (disableFallback !== undefined &&
54
+ disableFallback !== 'true' &&
55
+ disableFallback !== 'false') {
56
+ throw new Error('PRAXIS_DISABLE_NONSTREAMING_FALLBACK must be true or false');
57
+ }
41
58
  if (provider === 'openai' && webSearch !== undefined) {
42
59
  throw new Error('PRAXIS_ANTHROPIC_WEB_SEARCH requires PRAXIS_PROVIDER=anthropic');
43
60
  }
@@ -59,11 +76,14 @@ export function parseProviderEnvironment(environment) {
59
76
  ? 'https://api.anthropic.com/v1'
60
77
  : 'https://api.openai.com/v1'),
61
78
  deadlineMs,
79
+ connectTimeoutMs,
80
+ idleTimeoutMs,
62
81
  ...(maxOutputTokens === undefined
63
82
  ? {}
64
83
  : { maxOutputTokens: Number(maxOutputTokens) }),
65
84
  ...(anthropicVersion === undefined ? {} : { anthropicVersion }),
66
85
  ...(webSearch === undefined ? {} : { webSearch: webSearch === 'true' }),
86
+ disableNonStreamingFallback: disableFallback === 'true',
67
87
  };
68
88
  }
69
89
  export function parseContextEnvironment(environment) {
@@ -0,0 +1,17 @@
1
+ import { type ModelProvider, type ModelRequest, type ModelStreamEvent } from '../core/runtime.js';
2
+ /** Marks a provider error as safe for the bounded non-streaming replay. */
3
+ export declare function markNonStreamingFallbackEligible<T extends Error>(error: T): T;
4
+ export declare function isNonStreamingFallbackEligible(error: unknown): boolean;
5
+ /** Replays an eligible failed streaming request through one non-streaming call. */
6
+ export declare class NonStreamingFallbackModelProvider implements ModelProvider {
7
+ private readonly provider;
8
+ private readonly nonStreamingProvider;
9
+ constructor(options: {
10
+ provider: ModelProvider;
11
+ nonStreamingProvider?: ModelProvider;
12
+ });
13
+ readonly model?: string;
14
+ get capabilities(): ModelProvider['capabilities'];
15
+ complete(request: ModelRequest): AsyncIterable<ModelStreamEvent>;
16
+ }
17
+ //# sourceMappingURL=non-streaming-fallback-provider.d.ts.map
@@ -0,0 +1,107 @@
1
+ import { ModelProviderError, modelProviderErrorKind, } from '../core/runtime.js';
2
+ const eligibleErrors = new WeakSet();
3
+ /** Marks a provider error as safe for the bounded non-streaming replay. */
4
+ export function markNonStreamingFallbackEligible(error) {
5
+ eligibleErrors.add(error);
6
+ return error;
7
+ }
8
+ export function isNonStreamingFallbackEligible(error) {
9
+ return ((typeof error === 'object' &&
10
+ error !== null &&
11
+ eligibleErrors.has(error)) ||
12
+ (error instanceof ModelProviderError && error.timeoutPhase === 'idle'));
13
+ }
14
+ function cancellationError(signal) {
15
+ return new ModelProviderError('Provider request cancelled', {
16
+ kind: 'cancelled',
17
+ retryable: false,
18
+ cause: signal.reason,
19
+ });
20
+ }
21
+ function throwIfAborted(signal) {
22
+ if (signal?.aborted)
23
+ throw cancellationError(signal);
24
+ }
25
+ /** Replays an eligible failed streaming request through one non-streaming call. */
26
+ export class NonStreamingFallbackModelProvider {
27
+ provider;
28
+ nonStreamingProvider;
29
+ constructor(options) {
30
+ this.provider = options.provider;
31
+ this.nonStreamingProvider = options.nonStreamingProvider;
32
+ Object.defineProperty(this, 'model', {
33
+ configurable: false,
34
+ enumerable: true,
35
+ get: () => this.provider.model,
36
+ });
37
+ }
38
+ get capabilities() {
39
+ return this.provider.capabilities;
40
+ }
41
+ async *complete(request) {
42
+ const primaryEvents = [];
43
+ let primaryTerminal = false;
44
+ try {
45
+ for await (const event of this.provider.complete(request)) {
46
+ if (primaryTerminal) {
47
+ throw new ModelProviderError(`Provider emitted ${event.type} after its terminal event`, { retryable: false });
48
+ }
49
+ if (event.type === 'terminal')
50
+ primaryTerminal = true;
51
+ primaryEvents.push(event);
52
+ }
53
+ if (this.provider.capabilities.terminalReasons === true &&
54
+ !primaryTerminal) {
55
+ throw new ModelProviderError('Provider stream ended without a terminal reason', { retryable: true });
56
+ }
57
+ yield* primaryEvents;
58
+ return;
59
+ }
60
+ catch (error) {
61
+ throwIfAborted(request.signal);
62
+ if (!this.nonStreamingProvider ||
63
+ !isNonStreamingFallbackEligible(error)) {
64
+ throw error;
65
+ }
66
+ throwIfAborted(request.signal);
67
+ if (error instanceof ModelProviderError) {
68
+ yield {
69
+ type: 'api-retry',
70
+ attempt: 1,
71
+ maxRetries: 1,
72
+ retryDelayMs: 0,
73
+ errorStatus: error.status ?? null,
74
+ error: modelProviderErrorKind(error),
75
+ };
76
+ }
77
+ else {
78
+ // The retry event contract carries a typed provider error kind. An
79
+ // explicitly marked non-ModelProviderError is still replayable.
80
+ yield {
81
+ type: 'api-retry',
82
+ attempt: 1,
83
+ maxRetries: 1,
84
+ retryDelayMs: 0,
85
+ errorStatus: null,
86
+ error: 'server_error',
87
+ };
88
+ }
89
+ const fallbackEvents = [];
90
+ let fallbackTerminal = false;
91
+ for await (const event of this.nonStreamingProvider.complete(request)) {
92
+ if (fallbackTerminal) {
93
+ throw new ModelProviderError(`Provider emitted ${event.type} after its terminal event`, { retryable: false });
94
+ }
95
+ if (event.type === 'terminal')
96
+ fallbackTerminal = true;
97
+ fallbackEvents.push(event);
98
+ }
99
+ if (this.nonStreamingProvider.capabilities.terminalReasons === true &&
100
+ !fallbackTerminal) {
101
+ throw new ModelProviderError('Provider stream ended without a terminal reason', { retryable: true });
102
+ }
103
+ yield* fallbackEvents;
104
+ }
105
+ }
106
+ }
107
+ //# sourceMappingURL=non-streaming-fallback-provider.js.map
@@ -1,5 +1,6 @@
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';
3
4
  function isRecord(value) {
4
5
  return typeof value === 'object' && value !== null && !Array.isArray(value);
5
6
  }
@@ -351,7 +352,9 @@ export class OpenAICompatibleProvider {
351
352
  requestInit.signal = request.signal;
352
353
  let response;
353
354
  try {
355
+ reportProviderTransportActivity(request, 'request-started');
354
356
  response = await this.fetchImplementation(this.endpoint, requestInit);
357
+ reportProviderTransportActivity(request, 'response-received');
355
358
  }
356
359
  catch (error) {
357
360
  const kind = transportFailureKind(error, request.signal);
@@ -382,6 +385,8 @@ export class OpenAICompatibleProvider {
382
385
  ended = true;
383
386
  break;
384
387
  }
388
+ if (value.byteLength > 0)
389
+ reportProviderTransportActivity(request, 'response-chunk');
385
390
  size += value.byteLength;
386
391
  if (size > this.maxErrorBodyBytes) {
387
392
  throw new ModelProviderError(`Provider error response exceeded ${this.maxErrorBodyBytes} bytes`, { retryable: false, status: response.status });
@@ -434,6 +439,8 @@ export class OpenAICompatibleProvider {
434
439
  try {
435
440
  stream: while (true) {
436
441
  const { done, value } = await reader.read();
442
+ if (!done && value.byteLength > 0)
443
+ reportProviderTransportActivity(request, 'response-chunk');
437
444
  buffer += decoder.decode(value, { stream: !done });
438
445
  buffer = buffer.replaceAll('\r\n', '\n');
439
446
  if (Buffer.byteLength(buffer) > this.maxStreamBufferBytes) {
@@ -2,6 +2,7 @@ import { AnthropicCompatibleProvider } from './anthropic-compatible.js';
2
2
  import { OpenAICompatibleProvider } from './openai-compatible.js';
3
3
  import { CodexSubscriptionProvider } from './codex-subscription.js';
4
4
  import { DeadlineModelProvider } from './deadline-provider.js';
5
+ import { NonStreamingFallbackModelProvider } from './non-streaming-fallback-provider.js';
5
6
  import { CodexOAuthCredentialManager, } from './codex-oauth.js';
6
7
  import { resolveProviderTarget, } from './provider-settings.js';
7
8
  import { ProviderAuthenticationError, resolveProviderCredential, } from './provider-auth.js';
@@ -135,7 +136,7 @@ class NativeProviderRegistry {
135
136
  if (this.options.credential.type !== 'api-key') {
136
137
  throw new ProviderAuthenticationError('invalid_credential', 'Provider authentication failed: an API key is required');
137
138
  }
138
- return this.withDeadline(new AnthropicCompatibleProvider({
139
+ const anthropicOptions = {
139
140
  baseUrl: target.baseUrl,
140
141
  model: target.modelId,
141
142
  apiKey: this.options.credential.secret,
@@ -173,15 +174,34 @@ class NativeProviderRegistry {
173
174
  ...(this.options.fetchImplementation === undefined
174
175
  ? {}
175
176
  : { fetchImplementation: this.options.fetchImplementation }),
177
+ };
178
+ const streaming = this.withDeadline(new AnthropicCompatibleProvider({
179
+ ...anthropicOptions,
180
+ streaming: true,
176
181
  }));
182
+ if (this.options.providerEnvironment?.disableNonStreamingFallback === true)
183
+ return streaming;
184
+ const nonStreaming = this.withDeadline(new AnthropicCompatibleProvider({
185
+ ...anthropicOptions,
186
+ streaming: false,
187
+ }));
188
+ return new NonStreamingFallbackModelProvider({
189
+ provider: streaming,
190
+ nonStreamingProvider: nonStreaming,
191
+ });
177
192
  }
178
193
  throw new ProviderRegistryError('unsupported_provider', `Unsupported provider protocol: ${target.protocol}`);
179
194
  }
180
195
  withDeadline(provider) {
181
- const deadlineMs = this.options.providerEnvironment?.deadlineMs;
182
- return deadlineMs === undefined
196
+ const environment = this.options.providerEnvironment;
197
+ return environment === undefined
183
198
  ? provider
184
- : new DeadlineModelProvider({ provider, deadlineMs });
199
+ : new DeadlineModelProvider({
200
+ provider,
201
+ deadlineMs: environment.deadlineMs,
202
+ connectTimeoutMs: environment.connectTimeoutMs,
203
+ idleTimeoutMs: environment.idleTimeoutMs,
204
+ });
185
205
  }
186
206
  }
187
207
  //# sourceMappingURL=provider-registry.js.map
@@ -0,0 +1,7 @@
1
+ import type { ModelRequest } from '../core/runtime.js';
2
+ export type ProviderTransportActivity = 'request-started' | 'response-received' | 'response-chunk';
3
+ export type ProviderTransportActivityObserver = (activity: ProviderTransportActivity) => void;
4
+ export declare function observeProviderTransportActivity(request: ModelRequest, observer: ProviderTransportActivityObserver): void;
5
+ export declare function reportProviderTransportActivity(request: ModelRequest, activity: ProviderTransportActivity): void;
6
+ export declare function detachProviderTransportActivity(request: ModelRequest): void;
7
+ //# sourceMappingURL=provider-transport-activity.d.ts.map
@@ -0,0 +1,11 @@
1
+ const observers = new WeakMap();
2
+ export function observeProviderTransportActivity(request, observer) {
3
+ observers.set(request, observer);
4
+ }
5
+ export function reportProviderTransportActivity(request, activity) {
6
+ observers.get(request)?.(activity);
7
+ }
8
+ export function detachProviderTransportActivity(request) {
9
+ observers.delete(request);
10
+ }
11
+ //# sourceMappingURL=provider-transport-activity.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.48.1",
3
+ "version": "0.49.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",