praxis-agent 0.53.1 → 0.55.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.
@@ -2,12 +2,16 @@ import { type ModelProvider, type ModelStreamEvent, type ModelRequest } from '..
2
2
  export interface FallbackModelProviderOptions {
3
3
  providers: readonly ModelProvider[];
4
4
  retryDelayMs?: number;
5
+ routeScope?: 'completion' | 'turn';
5
6
  }
6
7
  /** Routes each complete request through Claude's retry-then-fallback policy. */
7
8
  export declare class FallbackModelProvider implements ModelProvider {
8
9
  private active;
9
10
  private readonly retryDelayMs;
10
- private readonly fallbackProviders;
11
+ private readonly configuredProviders;
12
+ private readonly routeScope;
13
+ private currentProviderIndex;
14
+ private routeSealed;
11
15
  constructor(options: FallbackModelProviderOptions);
12
16
  /** Capabilities of the currently active provider after any fallback routing. */
13
17
  get capabilities(): ModelProvider['capabilities'];
@@ -42,14 +42,18 @@ async function waitForRetry(delayMs, signal) {
42
42
  export class FallbackModelProvider {
43
43
  active;
44
44
  retryDelayMs;
45
- fallbackProviders;
45
+ configuredProviders;
46
+ routeScope;
47
+ currentProviderIndex = 0;
48
+ routeSealed = false;
46
49
  constructor(options) {
47
50
  if (options.providers.length === 0) {
48
51
  throw new Error('Fallback provider requires at least one provider');
49
52
  }
50
53
  this.active = options.providers[0];
51
- this.fallbackProviders = options.providers;
54
+ this.configuredProviders = options.providers;
52
55
  this.retryDelayMs = options.retryDelayMs ?? 500;
56
+ this.routeScope = options.routeScope ?? 'turn';
53
57
  }
54
58
  /** Capabilities of the currently active provider after any fallback routing. */
55
59
  get capabilities() {
@@ -60,9 +64,12 @@ export class FallbackModelProvider {
60
64
  }
61
65
  async *complete(request) {
62
66
  let lastError;
63
- for (const provider of this.providers()) {
67
+ for (const [providerIndex, provider] of this.providers()) {
68
+ if (providerIndex > 0)
69
+ validateCapabilities(provider, request);
64
70
  for (let attempt = 0; attempt < MAX_ATTEMPTS_PER_MODEL; attempt += 1) {
65
71
  throwIfAborted(request.signal);
72
+ this.currentProviderIndex = providerIndex;
66
73
  this.active = provider;
67
74
  const attemptStartedAt = performance.now();
68
75
  try {
@@ -97,6 +104,9 @@ export class FallbackModelProvider {
97
104
  if (provider.capabilities.terminalReasons === true && !terminalSeen) {
98
105
  throw new ModelProviderError('Provider stream ended without a terminal reason', { retryable: true });
99
106
  }
107
+ this.currentProviderIndex = providerIndex;
108
+ this.active = provider;
109
+ this.routeSealed = this.routeScope === 'turn';
100
110
  yield {
101
111
  type: 'api-attempt-duration',
102
112
  durationMs: attemptDurationMs ??
@@ -133,7 +143,55 @@ export class FallbackModelProvider {
133
143
  : new Error(String(lastError ?? 'All model providers failed'));
134
144
  }
135
145
  providers() {
136
- return this.fallbackProviders;
146
+ if (this.routeSealed) {
147
+ const provider = this.configuredProviders[this.currentProviderIndex];
148
+ return provider === undefined
149
+ ? []
150
+ : [[this.currentProviderIndex, provider]];
151
+ }
152
+ return this.configuredProviders.map((provider, index) => [index, provider]);
153
+ }
154
+ }
155
+ function validateCapabilities(provider, request) {
156
+ const capabilities = provider.capabilities;
157
+ if (!capabilities.streaming) {
158
+ throw capabilityError(provider, 'streaming');
159
+ }
160
+ if (request.tools !== undefined &&
161
+ request.tools.length > 0 &&
162
+ !capabilities.tools) {
163
+ throw capabilityError(provider, 'tools');
164
+ }
165
+ const hasImages = request.messages.some((message) => ('images' in message &&
166
+ message.images !== undefined &&
167
+ message.images.length > 0) ||
168
+ ('contentBlocks' in message &&
169
+ message.contentBlocks?.some((block) => block.type === 'image') ===
170
+ true));
171
+ if (hasImages && !capabilities.images)
172
+ throw capabilityError(provider, 'images');
173
+ const hasDocuments = request.messages.some((message) => ('documents' in message &&
174
+ message.documents !== undefined &&
175
+ message.documents.length > 0) ||
176
+ ('contentBlocks' in message &&
177
+ message.contentBlocks?.some((block) => block.type === 'document') ===
178
+ true));
179
+ if (hasDocuments && !capabilities.documents)
180
+ throw capabilityError(provider, 'documents');
181
+ if (request.webSearch !== undefined && !capabilities.webSearch) {
182
+ throw capabilityError(provider, 'webSearch');
137
183
  }
184
+ const thinking = request.thinking;
185
+ if (thinking?.maxTokens !== undefined && !capabilities.thinking?.maxTokens) {
186
+ throw capabilityError(provider, 'thinking maxTokens');
187
+ }
188
+ if (thinking !== undefined) {
189
+ if (!capabilities.thinking?.modes.includes(thinking.mode)) {
190
+ throw capabilityError(provider, `thinking mode ${thinking.mode}`);
191
+ }
192
+ }
193
+ }
194
+ function capabilityError(provider, capability) {
195
+ return new ModelProviderError(`Provider ${provider.model ?? 'unknown'} does not support ${capability}`, { kind: 'invalid_request', retryable: false });
138
196
  }
139
197
  //# sourceMappingURL=fallback-provider.js.map
@@ -0,0 +1,26 @@
1
+ import { type ModelProvider, type ModelRequest, type ModelStreamEvent, type ModelThinkingConfig } from '../core/runtime.js';
2
+ export interface OpenAIResponsesProviderOptions {
3
+ baseUrl: string;
4
+ apiKey: string;
5
+ model: string;
6
+ contextWindowTokens?: number;
7
+ thinking?: ModelThinkingConfig;
8
+ maxStreamBufferBytes?: number;
9
+ maxToolArgumentsBytes?: number;
10
+ maxToolCallsPerResponse?: number;
11
+ maxToolMetadataBytes?: number;
12
+ maxReasoningBytes?: number;
13
+ maxErrorBodyBytes?: number;
14
+ fetchImplementation?: typeof fetch;
15
+ }
16
+ export declare class OpenAIResponsesProvider implements ModelProvider {
17
+ private readonly options;
18
+ readonly model: string;
19
+ readonly capabilities: ModelProvider['capabilities'];
20
+ private readonly fetchImplementation;
21
+ private readonly codec;
22
+ private readonly maxErrorBodyBytes;
23
+ constructor(options: OpenAIResponsesProviderOptions);
24
+ complete(request: ModelRequest): AsyncIterable<ModelStreamEvent>;
25
+ }
26
+ //# sourceMappingURL=openai-responses.d.ts.map
@@ -0,0 +1,162 @@
1
+ import { ModelProviderError, } from '../core/runtime.js';
2
+ import { ResponsesCodec } from './responses-codec.js';
3
+ import { reportProviderTransportActivity } from './provider-transport-activity.js';
4
+ function error(kind, retryable, status) {
5
+ return new ModelProviderError(status === undefined
6
+ ? 'OpenAI Responses provider request failed'
7
+ : `OpenAI Responses provider request failed with HTTP ${status}`, { kind, retryable, ...(status === undefined ? {} : { status }) });
8
+ }
9
+ function statusError(status) {
10
+ if (status === 401 || status === 403)
11
+ return error('authentication_failed', false, status);
12
+ if (status === 402)
13
+ return error('billing_error', false, status);
14
+ if (status === 408)
15
+ return error('timeout', true, status);
16
+ if (status === 429)
17
+ return error('rate_limit', true, status);
18
+ if (status === 529)
19
+ return error('overloaded', true, status);
20
+ if (status >= 500)
21
+ return error('server_error', true, status);
22
+ return error('invalid_request', false, status);
23
+ }
24
+ function classifyErrorBody(body, status) {
25
+ let value;
26
+ try {
27
+ value = JSON.parse(body);
28
+ }
29
+ catch {
30
+ return statusError(status);
31
+ }
32
+ const record = value && typeof value === 'object'
33
+ ? value
34
+ : undefined;
35
+ const nested = record?.error && typeof record.error === 'object'
36
+ ? record.error
37
+ : record;
38
+ const text = [nested?.type, nested?.code, nested?.message]
39
+ .filter((item) => typeof item === 'string')
40
+ .join(' ');
41
+ if (/context[_ -]?length|prompt[_ -]?too[_ -]?long|context.{0,40}(?:exceed|too long)/iu.test(text))
42
+ return error('prompt_too_long', false, status);
43
+ if (nested?.type === 'overloaded_error')
44
+ return error('overloaded', true, status);
45
+ return statusError(status);
46
+ }
47
+ async function readErrorBody(response, maxBytes, request) {
48
+ const reader = response.body?.getReader();
49
+ if (!reader)
50
+ return '';
51
+ const decoder = new TextDecoder();
52
+ let body = '';
53
+ let remaining = maxBytes;
54
+ let ended = false;
55
+ try {
56
+ while (remaining > 0) {
57
+ const next = await reader.read();
58
+ if (next.done) {
59
+ ended = true;
60
+ body += decoder.decode();
61
+ break;
62
+ }
63
+ if (next.value.byteLength > 0)
64
+ reportProviderTransportActivity(request, 'response-chunk');
65
+ const chunk = next.value.subarray(0, remaining);
66
+ body += decoder.decode(chunk, { stream: chunk.byteLength === remaining });
67
+ remaining -= chunk.byteLength;
68
+ if (chunk.byteLength < next.value.byteLength)
69
+ break;
70
+ }
71
+ return body;
72
+ }
73
+ finally {
74
+ if (!ended) {
75
+ try {
76
+ await reader.cancel();
77
+ }
78
+ catch {
79
+ /* preserve the redacted HTTP error */
80
+ }
81
+ }
82
+ reader.releaseLock();
83
+ }
84
+ }
85
+ export class OpenAIResponsesProvider {
86
+ options;
87
+ model;
88
+ capabilities;
89
+ fetchImplementation;
90
+ codec;
91
+ maxErrorBodyBytes;
92
+ constructor(options) {
93
+ this.options = options;
94
+ this.model = options.model;
95
+ this.fetchImplementation = options.fetchImplementation ?? fetch;
96
+ this.codec = new ResponsesCodec({
97
+ providerLabel: 'OpenAI Responses provider',
98
+ ...options,
99
+ });
100
+ this.maxErrorBodyBytes = options.maxErrorBodyBytes ?? 64 * 1024;
101
+ if (!Number.isSafeInteger(this.maxErrorBodyBytes) ||
102
+ this.maxErrorBodyBytes <= 0)
103
+ throw new Error('OpenAI Responses provider limits must be positive integers');
104
+ this.capabilities = {
105
+ streaming: true,
106
+ usage: true,
107
+ tools: true,
108
+ images: true,
109
+ documents: false,
110
+ webSearch: false,
111
+ thinking: {
112
+ modes: ['disabled', 'enabled', 'adaptive'],
113
+ maxTokens: false,
114
+ },
115
+ ...(options.contextWindowTokens === undefined
116
+ ? {}
117
+ : { contextWindowTokens: options.contextWindowTokens }),
118
+ terminalReasons: true,
119
+ };
120
+ }
121
+ async *complete(request) {
122
+ const body = this.codec.serialize(request, this.model);
123
+ const signal = request.signal;
124
+ let response;
125
+ try {
126
+ reportProviderTransportActivity(request, 'request-started');
127
+ response = await this.fetchImplementation(`${this.options.baseUrl.replace(/\/+$/u, '')}/responses`, {
128
+ method: 'POST',
129
+ headers: {
130
+ Authorization: `Bearer ${this.options.apiKey}`,
131
+ accept: 'text/event-stream',
132
+ 'content-type': 'application/json',
133
+ },
134
+ body: JSON.stringify(body),
135
+ ...(signal ? { signal } : {}),
136
+ });
137
+ reportProviderTransportActivity(request, 'response-received');
138
+ }
139
+ catch {
140
+ if (signal?.aborted)
141
+ throw error('cancelled', false);
142
+ throw error('transport_error', true);
143
+ }
144
+ if (!response.ok) {
145
+ let body = '';
146
+ try {
147
+ body = await readErrorBody(response, this.maxErrorBodyBytes, request);
148
+ }
149
+ catch {
150
+ /* preserve the redacted status classification */
151
+ }
152
+ throw classifyErrorBody(body, response.status);
153
+ }
154
+ if (!response.body)
155
+ throw error('transport_error', true);
156
+ yield* this.codec.stream(response.body, {
157
+ ...(signal === undefined ? {} : { signal }),
158
+ onChunk: () => reportProviderTransportActivity(request, 'response-chunk'),
159
+ });
160
+ }
161
+ }
162
+ //# sourceMappingURL=openai-responses.js.map
@@ -1,5 +1,6 @@
1
1
  import { AnthropicCompatibleProvider } from './anthropic-compatible.js';
2
2
  import { OpenAICompatibleProvider } from './openai-compatible.js';
3
+ import { OpenAIResponsesProvider } from './openai-responses.js';
3
4
  import { CodexSubscriptionProvider } from './codex-subscription.js';
4
5
  import { DeadlineModelProvider } from './deadline-provider.js';
5
6
  import { NonStreamingFallbackModelProvider } from './non-streaming-fallback-provider.js';
@@ -132,6 +133,27 @@ class NativeProviderRegistry {
132
133
  : { fetchImplementation: this.options.fetchImplementation }),
133
134
  }));
134
135
  }
136
+ if (target.protocol === 'openai-responses') {
137
+ if (this.options.credential.type !== 'api-key') {
138
+ throw new ProviderAuthenticationError('invalid_credential', 'Provider authentication failed: an API key is required');
139
+ }
140
+ return this.withDeadline(new OpenAIResponsesProvider({
141
+ baseUrl: target.baseUrl,
142
+ model: target.modelId,
143
+ apiKey: this.options.credential.secret,
144
+ ...(this.options.context?.contextWindowTokens === undefined
145
+ ? {}
146
+ : {
147
+ contextWindowTokens: this.options.context.contextWindowTokens,
148
+ }),
149
+ ...(this.options.openAiThinking === undefined
150
+ ? {}
151
+ : { thinking: this.options.openAiThinking }),
152
+ ...(this.options.fetchImplementation === undefined
153
+ ? {}
154
+ : { fetchImplementation: this.options.fetchImplementation }),
155
+ }));
156
+ }
135
157
  if (target.protocol === 'anthropic-messages') {
136
158
  if (this.options.credential.type !== 'api-key') {
137
159
  throw new ProviderAuthenticationError('invalid_credential', 'Provider authentication failed: an API key is required');
@@ -1,4 +1,4 @@
1
- export type ProviderProtocol = 'openai-compatible' | 'anthropic-messages' | 'codex-subscription';
1
+ export type ProviderProtocol = 'openai-compatible' | 'openai-responses' | 'anthropic-messages' | 'codex-subscription';
2
2
  export type CredentialSource = {
3
3
  source: 'env';
4
4
  name: string;
@@ -18,6 +18,15 @@ const BUILT_INS = {
18
18
  },
19
19
  },
20
20
  },
21
+ 'openai-responses': {
22
+ protocol: 'openai-responses',
23
+ profiles: {
24
+ default: {
25
+ baseUrl: 'https://api.openai.com/v1',
26
+ credential: { source: 'env', name: 'OPENAI_API_KEY' },
27
+ },
28
+ },
29
+ },
21
30
  anthropic: {
22
31
  protocol: 'anthropic-messages',
23
32
  profiles: {
@@ -148,8 +157,10 @@ function parseProvider(value, providerId) {
148
157
  'profiles',
149
158
  ]);
150
159
  const protocol = value.protocol;
151
- if (protocol !== 'openai-compatible' && protocol !== 'anthropic-messages') {
152
- fail(`providers.${providerId}.protocol must be openai-compatible or anthropic-messages`);
160
+ if (protocol !== 'openai-compatible' &&
161
+ protocol !== 'openai-responses' &&
162
+ protocol !== 'anthropic-messages') {
163
+ fail(`providers.${providerId}.protocol must be openai-compatible, openai-responses, or anthropic-messages`);
153
164
  }
154
165
  if (!isRecord(value.profiles) || Object.keys(value.profiles).length === 0)
155
166
  fail(`providers.${providerId}.profiles must be a non-empty object`);
@@ -0,0 +1,31 @@
1
+ import { type ModelRequest, type ModelStreamEvent, type ModelThinkingConfig } from '../core/runtime.js';
2
+ export interface ResponsesCodecOptions {
3
+ providerLabel?: string;
4
+ thinking?: ModelThinkingConfig;
5
+ maxStreamBufferBytes?: number;
6
+ maxToolArgumentsBytes?: number;
7
+ maxToolCallsPerResponse?: number;
8
+ maxToolMetadataBytes?: number;
9
+ maxReasoningBytes?: number;
10
+ }
11
+ export interface ResponsesStreamOptions {
12
+ signal?: AbortSignal;
13
+ onChunk?: () => void;
14
+ }
15
+ export declare class ResponsesCodec {
16
+ readonly maxStreamBufferBytes: number;
17
+ readonly maxToolArgumentsBytes: number;
18
+ readonly maxToolCallsPerResponse: number;
19
+ readonly maxToolMetadataBytes: number;
20
+ readonly maxReasoningBytes: number;
21
+ private readonly label;
22
+ private readonly configuredThinking;
23
+ constructor(options?: ResponsesCodecOptions);
24
+ serialize(request: ModelRequest, model: string): Record<string, unknown>;
25
+ stream(body: ReadableStream<Uint8Array>, options?: ResponsesStreamOptions): AsyncIterable<ModelStreamEvent>;
26
+ private createParser;
27
+ private parseFrame;
28
+ private contextualize;
29
+ private failure;
30
+ }
31
+ //# sourceMappingURL=responses-codec.d.ts.map