@navels/neal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +527 -0
  3. package/SECURITY.md +91 -0
  4. package/config.yml +104 -0
  5. package/dist/neal/activity-footer.js +177 -0
  6. package/dist/neal/activity-reporting.js +1 -0
  7. package/dist/neal/adjudicator/artifacts.js +58 -0
  8. package/dist/neal/adjudicator/blocked-adjudicator.js +223 -0
  9. package/dist/neal/adjudicator/contracts.js +139 -0
  10. package/dist/neal/adjudicator/execute.js +611 -0
  11. package/dist/neal/adjudicator/final-completion.js +104 -0
  12. package/dist/neal/adjudicator/planning.js +145 -0
  13. package/dist/neal/adjudicator/specs.js +453 -0
  14. package/dist/neal/agents/prompts.js +120 -0
  15. package/dist/neal/agents/rounds.js +706 -0
  16. package/dist/neal/agents/schemas.js +832 -0
  17. package/dist/neal/agents/structured-coder.js +82 -0
  18. package/dist/neal/agents/structured-json.js +528 -0
  19. package/dist/neal/agents.js +4 -0
  20. package/dist/neal/atomic-write.js +18 -0
  21. package/dist/neal/blocked-guidance.js +406 -0
  22. package/dist/neal/cli.js +471 -0
  23. package/dist/neal/commands/check.js +401 -0
  24. package/dist/neal/commands/compat.js +807 -0
  25. package/dist/neal/commands/interactive-activity.js +57 -0
  26. package/dist/neal/commands/new-run.js +79 -0
  27. package/dist/neal/commands/plan-and-execute.js +44 -0
  28. package/dist/neal/commands/recovery-guidance.js +217 -0
  29. package/dist/neal/commands/resume-run.js +395 -0
  30. package/dist/neal/commands/review.js +21 -0
  31. package/dist/neal/commands/runtime.js +557 -0
  32. package/dist/neal/commands/setup.js +596 -0
  33. package/dist/neal/commands/squash.js +113 -0
  34. package/dist/neal/commands/status.js +33 -0
  35. package/dist/neal/commands/writer-exit-codes.js +42 -0
  36. package/dist/neal/commit-message.js +17 -0
  37. package/dist/neal/config.js +432 -0
  38. package/dist/neal/context/artifacts.js +140 -0
  39. package/dist/neal/context/context.js +324 -0
  40. package/dist/neal/context/inline-review-context.js +131 -0
  41. package/dist/neal/context/reviewer-context.js +166 -0
  42. package/dist/neal/context/shared.js +117 -0
  43. package/dist/neal/context/types.js +1 -0
  44. package/dist/neal/diagnostic.js +208 -0
  45. package/dist/neal/execute-finalization.js +5 -0
  46. package/dist/neal/final-completion-review.js +188 -0
  47. package/dist/neal/final-completion.js +229 -0
  48. package/dist/neal/git.js +339 -0
  49. package/dist/neal/index.js +135 -0
  50. package/dist/neal/interactive-controls.js +85 -0
  51. package/dist/neal/logger.js +102 -0
  52. package/dist/neal/manual-gates.js +121 -0
  53. package/dist/neal/orchestrator/artifacts.js +70 -0
  54. package/dist/neal/orchestrator/completion.js +531 -0
  55. package/dist/neal/orchestrator/failures.js +31 -0
  56. package/dist/neal/orchestrator/notifications.js +175 -0
  57. package/dist/neal/orchestrator/phases/coder.js +516 -0
  58. package/dist/neal/orchestrator/phases/planning.js +540 -0
  59. package/dist/neal/orchestrator/phases/recovery.js +798 -0
  60. package/dist/neal/orchestrator/phases/review.js +136 -0
  61. package/dist/neal/orchestrator/phases/shared.js +279 -0
  62. package/dist/neal/orchestrator/run-loop.js +113 -0
  63. package/dist/neal/orchestrator/split-plan.js +235 -0
  64. package/dist/neal/orchestrator/transitions.js +309 -0
  65. package/dist/neal/orchestrator.js +215 -0
  66. package/dist/neal/phase-display.js +27 -0
  67. package/dist/neal/plan-doc.js +154 -0
  68. package/dist/neal/plan-queue.js +1092 -0
  69. package/dist/neal/plan-refinement.js +39 -0
  70. package/dist/neal/plan-validation.js +525 -0
  71. package/dist/neal/progress.js +237 -0
  72. package/dist/neal/prompts/assert-builder.js +13 -0
  73. package/dist/neal/prompts/execute.js +290 -0
  74. package/dist/neal/prompts/guidance.js +70 -0
  75. package/dist/neal/prompts/planning.js +313 -0
  76. package/dist/neal/prompts/review-doctrine.js +142 -0
  77. package/dist/neal/prompts/shared.js +101 -0
  78. package/dist/neal/prompts/specialized.js +212 -0
  79. package/dist/neal/prompts/specs.js +572 -0
  80. package/dist/neal/providers/anthropic-claude.js +1599 -0
  81. package/dist/neal/providers/detection.js +139 -0
  82. package/dist/neal/providers/generic-agentic-tools.js +586 -0
  83. package/dist/neal/providers/generic-agentic.js +1238 -0
  84. package/dist/neal/providers/liveness.js +151 -0
  85. package/dist/neal/providers/openai-codex.js +1014 -0
  86. package/dist/neal/providers/openai-compatible.js +654 -0
  87. package/dist/neal/providers/registry.js +389 -0
  88. package/dist/neal/providers/telemetry.js +208 -0
  89. package/dist/neal/providers/types.js +21 -0
  90. package/dist/neal/recovery-artifacts.js +50 -0
  91. package/dist/neal/resume-decision.js +220 -0
  92. package/dist/neal/resume-planner.js +265 -0
  93. package/dist/neal/retrospective.js +391 -0
  94. package/dist/neal/review-debt.js +18 -0
  95. package/dist/neal/review-findings/artifacts.js +173 -0
  96. package/dist/neal/review-findings/prompts.js +172 -0
  97. package/dist/neal/review-findings/provider.js +330 -0
  98. package/dist/neal/review-findings/run.js +373 -0
  99. package/dist/neal/review-findings/types.js +1 -0
  100. package/dist/neal/review-mode.js +67 -0
  101. package/dist/neal/review.js +137 -0
  102. package/dist/neal/run-lock.js +334 -0
  103. package/dist/neal/run-metrics.js +355 -0
  104. package/dist/neal/run-narrative-types.js +1 -0
  105. package/dist/neal/run-narrative.js +1374 -0
  106. package/dist/neal/run-registry.js +218 -0
  107. package/dist/neal/run-status.js +25 -0
  108. package/dist/neal/scopes.js +451 -0
  109. package/dist/neal/sensitive-text.js +8 -0
  110. package/dist/neal/squash-message.js +379 -0
  111. package/dist/neal/squash.js +591 -0
  112. package/dist/neal/state-invariants.js +496 -0
  113. package/dist/neal/state-views.js +344 -0
  114. package/dist/neal/state.js +887 -0
  115. package/dist/neal/status-footer.js +258 -0
  116. package/dist/neal/status.js +1260 -0
  117. package/dist/neal/storage-paths.js +57 -0
  118. package/dist/neal/support.js +58 -0
  119. package/dist/neal/terminal-narrator.js +435 -0
  120. package/dist/neal/types.js +1 -0
  121. package/dist/neal/verification-events.js +81 -0
  122. package/dist/neal/version.js +37 -0
  123. package/dist/neal/worktree-status.js +137 -0
  124. package/dist/notifier.js +44 -0
  125. package/docs/ADJUDICATOR_INVENTORY.md +310 -0
  126. package/docs/PROMPT_SPECS.md +266 -0
  127. package/docs/README.md +22 -0
  128. package/docs/architecture.md +113 -0
  129. package/docs/assets/neal-execution-flow.png +0 -0
  130. package/docs/automation.md +65 -0
  131. package/docs/comparison.md +105 -0
  132. package/docs/compat.md +269 -0
  133. package/docs/compatible-models.md +135 -0
  134. package/docs/demo.md +55 -0
  135. package/docs/maintenance.md +64 -0
  136. package/docs/plan-format.md +213 -0
  137. package/docs/providers.md +751 -0
  138. package/docs/release.md +147 -0
  139. package/docs/state-machine.md +266 -0
  140. package/docs/storage.md +207 -0
  141. package/docs/troubleshooting.md +152 -0
  142. package/examples/compat/add-edit-verify/PLAN.md +29 -0
  143. package/examples/compat/add-edit-verify/broken.diff +8 -0
  144. package/examples/compat/add-edit-verify/good.diff +8 -0
  145. package/examples/compat/add-edit-verify/package.json +5 -0
  146. package/examples/compat/add-edit-verify/src/add.js +2 -0
  147. package/examples/compat/add-edit-verify/test/add.test.js +9 -0
  148. package/examples/compat/is-even-add-test/PLAN.md +30 -0
  149. package/examples/compat/is-even-add-test/broken.diff +11 -0
  150. package/examples/compat/is-even-add-test/good.diff +11 -0
  151. package/examples/compat/is-even-add-test/package.json +5 -0
  152. package/examples/compat/is-even-add-test/src/is-even.js +3 -0
  153. package/examples/compat/is-even-add-test/test/is-even.test.js +9 -0
  154. package/examples/compat/manifest.json +60 -0
  155. package/examples/compat/plan-greeting/ISSUE.md +25 -0
  156. package/examples/compat/plan-greeting/package.json +5 -0
  157. package/examples/compat/plan-greeting/src/greet.js +2 -0
  158. package/examples/compat/plan-greeting/test/greet.test.js +8 -0
  159. package/examples/compat/reverse-grep-edit/PLAN.md +32 -0
  160. package/examples/compat/reverse-grep-edit/broken.diff +12 -0
  161. package/examples/compat/reverse-grep-edit/good.diff +12 -0
  162. package/examples/compat/reverse-grep-edit/package.json +5 -0
  163. package/examples/compat/reverse-grep-edit/src/strings.js +10 -0
  164. package/examples/compat/reverse-grep-edit/test/strings.test.js +15 -0
  165. package/examples/issue-triage-js/PLAN.md +83 -0
  166. package/examples/issue-triage-js/README.md +76 -0
  167. package/examples/issue-triage-js/package.json +9 -0
  168. package/examples/issue-triage-js/src/issue-triage.js +87 -0
  169. package/examples/issue-triage-js/test/issue-triage.test.js +107 -0
  170. package/package.json +70 -0
@@ -0,0 +1,654 @@
1
+ /**
2
+ * OpenAI-compatible structured-advisor provider on the AI SDK chat path
3
+ * (`@ai-sdk/openai-compatible`), replacing the bespoke `openai` npm client
4
+ * (Phase 2 adapter consolidation). The external contract is unchanged: same
5
+ * provider id, same config block, same capabilities (chat-only, no tools,
6
+ * no-read reviewer with inline context), same `NealProviderError`
7
+ * kinds/retryability, same provider events. The one sanctioned observable
8
+ * change is the `usage` payload shape, which is now the AI SDK's normalized
9
+ * shape (`inputTokens`/`outputTokens`/`totalTokens` plus details and a `raw`
10
+ * passthrough) instead of the raw response-body object.
11
+ *
12
+ * Gateway behaviors learned from live failures are preserved through a
13
+ * neal-owned `fetch` interceptor that inspects HTTP-200 JSON bodies before
14
+ * the SDK parses them (structural markers only — never text matching on
15
+ * content channels):
16
+ *
17
+ * - upstream errors embedded as a top-level `error` object inside an HTTP
18
+ * 200 body (OpenRouter wraps upstream 429s this way) are mapped with the
19
+ * same status table as real HTTP errors so rate limits stay retryable;
20
+ * - responses with no assistant text in `message.content`,
21
+ * `message.reasoning`, or `message.reasoning_content` are a retryable
22
+ * `api_error` flowing into the bounded api-retry loop.
23
+ *
24
+ * Reasoning-model fallback (final text in `message.reasoning` /
25
+ * `message.reasoning_content` with empty `content`) is handled natively by
26
+ * the AI SDK, which surfaces both fields as `reasoningText`; the turn code
27
+ * prefers `result.text` and falls back to `result.reasoningText`.
28
+ */
29
+ import { randomBytes } from 'node:crypto';
30
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
31
+ import { generateText } from 'ai';
32
+ import { runStructuredJsonProtocol } from '../agents/structured-json.js';
33
+ import { getOpenAICompatibleSettings } from '../config.js';
34
+ import { NealProviderError } from './types.js';
35
+ const OPENAI_COMPATIBLE_PROVIDER_ID = 'openai-compatible';
36
+ const API_RETRY_BASE_DELAY_MS = 500;
37
+ const API_RETRY_MAX_DELAY_MS = 5_000;
38
+ // Network-level error codes treated as transient (mirrors the
39
+ // generic-agentic classification; statusCode 408/429/5xx, explicit
40
+ // isRetryable, or one of these codes on the error or its cause).
41
+ const NETWORK_ERROR_CODES = new Set([
42
+ 'ECONNRESET',
43
+ 'ECONNREFUSED',
44
+ 'ETIMEDOUT',
45
+ 'EPIPE',
46
+ 'ENOTFOUND',
47
+ 'EAI_AGAIN',
48
+ 'UND_ERR_SOCKET',
49
+ 'UND_ERR_CONNECT_TIMEOUT',
50
+ ]);
51
+ function defaultSleep(ms) {
52
+ return new Promise((resolve) => {
53
+ setTimeout(resolve, ms);
54
+ });
55
+ }
56
+ function createDefaultOpenAICompatibleModel(args) {
57
+ // maxRetries: 0 on each generateText call keeps Neal's own apiRetryLimit
58
+ // retry loop the only retry layer for this provider.
59
+ return createOpenAICompatible({
60
+ name: OPENAI_COMPATIBLE_PROVIDER_ID,
61
+ baseURL: args.baseUrl,
62
+ apiKey: args.apiKey,
63
+ headers: args.headers,
64
+ fetch: args.fetch,
65
+ }).chatModel(args.model);
66
+ }
67
+ function createOpenAICompatibleProviderError(args) {
68
+ return new NealProviderError({
69
+ message: args.message,
70
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
71
+ role: 'structured-advisor',
72
+ sessionHandle: args.sessionHandle,
73
+ kind: args.kind,
74
+ retryable: args.retryable,
75
+ cause: args.cause,
76
+ });
77
+ }
78
+ function readStatusCode(error) {
79
+ const status = error.statusCode;
80
+ return typeof status === 'number' && Number.isFinite(status) ? status : null;
81
+ }
82
+ function isAbortLikeError(error) {
83
+ // AbortSignal.timeout aborts with a DOMException named TimeoutError on
84
+ // Node >= 22; caller AbortController.abort() defaults to AbortError. The
85
+ // AI SDK propagates the abort reason when maxRetries is 0.
86
+ const name = error.name;
87
+ return name === 'TimeoutError' || name === 'AbortError';
88
+ }
89
+ // Transient classification for errors WITHOUT an HTTP status: an explicit
90
+ // SDK isRetryable marker or a known network error code (on the error or its
91
+ // cause). Status-bearing errors must never reach this check — Neal's own
92
+ // status table is the contract for those, and the AI SDK's APICallError
93
+ // default marks statuses Neal treats as non-retryable (for example 409) as
94
+ // retryable.
95
+ function hasTransientStatuslessShape(error) {
96
+ if (error.isRetryable === true) {
97
+ return true;
98
+ }
99
+ const cause = error.cause;
100
+ const codes = [
101
+ error.code,
102
+ typeof cause === 'object' && cause !== null ? cause.code : undefined,
103
+ ];
104
+ return codes.some((code) => typeof code === 'string' && NETWORK_ERROR_CODES.has(code));
105
+ }
106
+ function describeError(error) {
107
+ if (error instanceof Error) {
108
+ return error.message;
109
+ }
110
+ if (typeof error === 'object' && error !== null) {
111
+ const candidate = error;
112
+ return typeof candidate.message === 'string' ? candidate.message : String(error);
113
+ }
114
+ return String(error);
115
+ }
116
+ /**
117
+ * Normalizes any thrown value into a NealProviderError with the bespoke
118
+ * adapter's classification table, expressed over AI SDK error shapes
119
+ * (structural markers only: `statusCode`, `isRetryable`, network `code`s,
120
+ * abort names) plus the abort-source disambiguation contract:
121
+ *
122
+ * - caller signal aborted -> `timeout`, retryable: false (the caller owns
123
+ * the cancellation; never retried in-round);
124
+ * - abort without a caller abort -> inactivity expiry -> `timeout`,
125
+ * retryable: true;
126
+ * - HTTP 401/403 -> `permission_denied`, non-retryable;
127
+ * - HTTP 408/429/5xx -> `api_error`, retryable;
128
+ * - any other HTTP status -> `provider_failed`, non-retryable (Neal's
129
+ * status table is authoritative for status-bearing errors; the SDK's
130
+ * `isRetryable` flag is never consulted for them because its default
131
+ * marks 409 retryable, which this contract does not);
132
+ * - no HTTP status with explicit isRetryable or a network error code ->
133
+ * `api_error`, retryable;
134
+ * - everything else -> `provider_failed`, non-retryable.
135
+ */
136
+ function normalizeOpenAICompatibleError(error, ctx) {
137
+ if (error instanceof NealProviderError) {
138
+ return error;
139
+ }
140
+ if (ctx.callerSignal?.aborted) {
141
+ return createOpenAICompatibleProviderError({
142
+ message: `OpenAI-compatible ${ctx.label} request was aborted before completion.`,
143
+ sessionHandle: ctx.sessionHandle,
144
+ kind: 'timeout',
145
+ retryable: false,
146
+ cause: error,
147
+ });
148
+ }
149
+ if (typeof error === 'object' && error !== null) {
150
+ if (isAbortLikeError(error)) {
151
+ return createOpenAICompatibleProviderError({
152
+ message: `OpenAI-compatible ${ctx.label} request exceeded the inactivity timeout and was aborted.`,
153
+ sessionHandle: ctx.sessionHandle,
154
+ kind: 'timeout',
155
+ retryable: true,
156
+ cause: error,
157
+ });
158
+ }
159
+ const status = readStatusCode(error);
160
+ if (status !== null) {
161
+ // Status-bearing errors follow Neal's explicit table exclusively;
162
+ // the SDK's isRetryable flag is intentionally not consulted here.
163
+ if (status === 401 || status === 403) {
164
+ return createOpenAICompatibleProviderError({
165
+ message: `OpenAI-compatible ${ctx.label} request was rejected with HTTP ${status}: ${describeError(error)}`,
166
+ sessionHandle: ctx.sessionHandle,
167
+ kind: 'permission_denied',
168
+ retryable: false,
169
+ cause: error,
170
+ });
171
+ }
172
+ if (status === 408 || status === 429 || status >= 500) {
173
+ return createOpenAICompatibleProviderError({
174
+ message: `OpenAI-compatible ${ctx.label} request failed with HTTP ${status}: ${describeError(error)}`,
175
+ sessionHandle: ctx.sessionHandle,
176
+ kind: 'api_error',
177
+ retryable: true,
178
+ cause: error,
179
+ });
180
+ }
181
+ return createOpenAICompatibleProviderError({
182
+ message: `OpenAI-compatible ${ctx.label} request failed with HTTP ${status}: ${describeError(error)}`,
183
+ sessionHandle: ctx.sessionHandle,
184
+ kind: 'provider_failed',
185
+ retryable: false,
186
+ cause: error,
187
+ });
188
+ }
189
+ if (hasTransientStatuslessShape(error)) {
190
+ return createOpenAICompatibleProviderError({
191
+ message: `OpenAI-compatible ${ctx.label} request failed with a network error: ${describeError(error)}`,
192
+ sessionHandle: ctx.sessionHandle,
193
+ kind: 'api_error',
194
+ retryable: true,
195
+ cause: error,
196
+ });
197
+ }
198
+ }
199
+ return createOpenAICompatibleProviderError({
200
+ message: `OpenAI-compatible ${ctx.label} request failed: ${describeError(error)}`,
201
+ sessionHandle: ctx.sessionHandle,
202
+ kind: 'provider_failed',
203
+ retryable: false,
204
+ cause: error,
205
+ });
206
+ }
207
+ const EMBEDDED_ERROR_RAW_DETAIL_MAX_CHARS = 400;
208
+ function extractEmbeddedErrorStatus(code) {
209
+ if (typeof code === 'number' && Number.isFinite(code)) {
210
+ return code;
211
+ }
212
+ if (typeof code === 'string' && /^\d+$/.test(code)) {
213
+ return Number(code);
214
+ }
215
+ return null;
216
+ }
217
+ // Some OpenAI-compatible gateways (for example OpenRouter) report upstream
218
+ // provider failures as an `error` object inside an HTTP 200 body instead of a
219
+ // non-200 status. Map those embedded errors with the same status rules as real
220
+ // HTTP errors so upstream rate limits and outages stay retryable.
221
+ function createEmbeddedResponseError(args) {
222
+ const errorObject = typeof args.embeddedError === 'object' && args.embeddedError !== null
223
+ ? args.embeddedError
224
+ : null;
225
+ const status = extractEmbeddedErrorStatus(errorObject?.code);
226
+ const messageText = errorObject && typeof errorObject.message === 'string' && errorObject.message
227
+ ? errorObject.message
228
+ : JSON.stringify(args.embeddedError);
229
+ const metadata = errorObject?.metadata;
230
+ const rawDetail = metadata && typeof metadata === 'object' && typeof metadata.raw === 'string'
231
+ ? ` (${String(metadata.raw).slice(0, EMBEDDED_ERROR_RAW_DETAIL_MAX_CHARS)})`
232
+ : '';
233
+ const message = `OpenAI-compatible ${args.label} response body reported ` +
234
+ `${status === null ? 'an error' : `error code ${status}`}: ${messageText}${rawDetail}`;
235
+ if (status === 401 || status === 403) {
236
+ return createOpenAICompatibleProviderError({
237
+ message,
238
+ sessionHandle: args.sessionHandle,
239
+ kind: 'permission_denied',
240
+ retryable: false,
241
+ });
242
+ }
243
+ if (status === 408 || status === 429 || (status !== null && status >= 500)) {
244
+ return createOpenAICompatibleProviderError({
245
+ message,
246
+ sessionHandle: args.sessionHandle,
247
+ kind: 'api_error',
248
+ retryable: true,
249
+ });
250
+ }
251
+ return createOpenAICompatibleProviderError({
252
+ message,
253
+ sessionHandle: args.sessionHandle,
254
+ kind: 'provider_failed',
255
+ retryable: false,
256
+ });
257
+ }
258
+ // Reasoning models served through OpenAI-compatible gateways can return
259
+ // `message.content` as null or empty while the final text lands in
260
+ // `message.reasoning` (OpenRouter) or `message.reasoning_content` (DeepSeek
261
+ //-style APIs), for example when the response budget is consumed by reasoning
262
+ // tokens. The AI SDK surfaces both fields as reasoning text, so a body with
263
+ // text in any of these fields is usable; a body with text in none of them is
264
+ // a transient upstream failure (see createInterceptingFetch).
265
+ function extractAssistantText(message) {
266
+ if (typeof message?.content === 'string' && message.content !== '') {
267
+ return message.content;
268
+ }
269
+ if (typeof message?.reasoning === 'string' && message.reasoning !== '') {
270
+ return message.reasoning;
271
+ }
272
+ if (typeof message?.reasoning_content === 'string' && message.reasoning_content !== '') {
273
+ return message.reasoning_content;
274
+ }
275
+ return null;
276
+ }
277
+ function describeMissingAssistantText(response) {
278
+ const choice = response.choices?.[0];
279
+ if (!choice) {
280
+ return 'choices[0] was missing';
281
+ }
282
+ const message = choice.message;
283
+ const finishReason = typeof choice.finish_reason === 'string' ? choice.finish_reason : 'absent';
284
+ if (!message) {
285
+ return `choices[0].message was missing; finish_reason=${finishReason}`;
286
+ }
287
+ const describeField = (value) => value === undefined ? 'absent' : value === null ? 'null' : value === '' ? 'empty' : 'present';
288
+ return (`content=${describeField(message.content)}, reasoning=${describeField(message.reasoning)}, ` +
289
+ `reasoning_content=${describeField(message.reasoning_content)}; finish_reason=${finishReason}`);
290
+ }
291
+ /**
292
+ * Neal-owned fetch interceptor wired into the AI SDK provider. It inspects
293
+ * successful (2xx) JSON response bodies before the SDK parses them and
294
+ * throws the mapped NealProviderError for two gateway behaviors the SDK
295
+ * does not classify:
296
+ *
297
+ * - a top-level `error` object inside an HTTP 200 body (structural marker;
298
+ * mapped by createEmbeddedResponseError's status table);
299
+ * - no assistant text in `message.content`, `message.reasoning`, or
300
+ * `message.reasoning_content` (retryable `api_error`, flowing into the
301
+ * bounded api-retry loop).
302
+ *
303
+ * Both checks gate on structural body shape — presence of an `error`
304
+ * object, absence of text fields — never on matching text that can contain
305
+ * document content (the channel rule). Non-2xx responses pass through
306
+ * untouched so the SDK's error handler attaches `statusCode` for
307
+ * normalizeOpenAICompatibleError. Thrown NealProviderErrors propagate
308
+ * unchanged through the SDK (it rethrows non-network fetch errors as-is and
309
+ * performs no internal retries with maxRetries: 0).
310
+ */
311
+ function createInterceptingFetch(ctx) {
312
+ return async (input, init) => {
313
+ const response = await fetch(input, init);
314
+ if (!response.ok) {
315
+ return response;
316
+ }
317
+ const contentType = response.headers.get('content-type') ?? '';
318
+ if (!contentType.toLowerCase().includes('json')) {
319
+ return response;
320
+ }
321
+ let body;
322
+ try {
323
+ body = await response.clone().json();
324
+ }
325
+ catch {
326
+ // Let the SDK's own response handling report unparseable bodies.
327
+ return response;
328
+ }
329
+ if (typeof body !== 'object' || body === null) {
330
+ return response;
331
+ }
332
+ const completion = body;
333
+ if (completion.error !== undefined && completion.error !== null) {
334
+ throw createEmbeddedResponseError({
335
+ embeddedError: completion.error,
336
+ label: ctx.label,
337
+ sessionHandle: ctx.sessionHandle,
338
+ });
339
+ }
340
+ if (extractAssistantText(completion.choices?.[0]?.message) === null) {
341
+ // A response with no assistant text in any known field is treated as
342
+ // a transient upstream failure so it flows into the bounded API retry
343
+ // loop instead of permanently failing the round.
344
+ throw createOpenAICompatibleProviderError({
345
+ message: `OpenAI-compatible ${ctx.label} response did not include assistant text ` +
346
+ `(${describeMissingAssistantText(completion)}).`,
347
+ sessionHandle: ctx.sessionHandle,
348
+ kind: 'api_error',
349
+ retryable: true,
350
+ });
351
+ }
352
+ return response;
353
+ };
354
+ }
355
+ function buildSyntheticSessionHandle() {
356
+ // Synthetic per-round handle for events only. This provider has no session
357
+ // resume; runStructuredRound always returns sessionHandle: null so no
358
+ // resumable reviewer handle is ever persisted for it.
359
+ return `${OPENAI_COMPATIBLE_PROVIDER_ID}:${new Date().toISOString()}:${randomBytes(4).toString('hex')}`;
360
+ }
361
+ function getApiRetryDelayMs(retryCount) {
362
+ return Math.min(API_RETRY_BASE_DELAY_MS * 2 ** (retryCount - 1), API_RETRY_MAX_DELAY_MS);
363
+ }
364
+ function composeAbortSignal(callerSignal, inactivityTimeoutMs) {
365
+ // Non-streaming chat turn, so the inactivity timeout acts as a
366
+ // whole-request timeout here. That is acceptable for advisor rounds, which
367
+ // are single prompt-in/text-out calls.
368
+ const timeoutSignal = AbortSignal.timeout(inactivityTimeoutMs);
369
+ return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
370
+ }
371
+ async function emitProviderEvent(events, event) {
372
+ await events?.(event);
373
+ }
374
+ function resolveRoundSettings(args) {
375
+ let settings;
376
+ try {
377
+ settings = args.resolveSettings(args.cwd);
378
+ }
379
+ catch (error) {
380
+ const message = error instanceof Error ? error.message : String(error);
381
+ throw createOpenAICompatibleProviderError({
382
+ message: `OpenAI-compatible ${args.label} settings could not be resolved: ${message}`,
383
+ kind: 'provider_failed',
384
+ retryable: false,
385
+ cause: error,
386
+ });
387
+ }
388
+ if (!settings.baseUrl) {
389
+ throw createOpenAICompatibleProviderError({
390
+ message: `OpenAI-compatible ${args.label} round has no base URL. ` +
391
+ 'Set providers.openai_compatible.base_url in config.yml or the OPENAI_COMPATIBLE_BASE_URL environment variable.',
392
+ kind: 'provider_failed',
393
+ retryable: false,
394
+ });
395
+ }
396
+ const model = args.roundModel ?? args.roleModel ?? settings.defaultModel;
397
+ if (!model) {
398
+ throw createOpenAICompatibleProviderError({
399
+ message: `OpenAI-compatible ${args.label} round has no model. ` +
400
+ 'Set a role model override (for example agent.reviewer.model), providers.openai_compatible.default_model in config.yml, or the OPENAI_COMPATIBLE_MODEL environment variable.',
401
+ kind: 'provider_failed',
402
+ retryable: false,
403
+ });
404
+ }
405
+ if (!settings.apiKey) {
406
+ throw createOpenAICompatibleProviderError({
407
+ message: `OpenAI-compatible ${args.label} round has no API key. ` +
408
+ `Set the ${settings.apiKeyEnv} environment variable.`,
409
+ kind: 'permission_denied',
410
+ retryable: false,
411
+ });
412
+ }
413
+ return {
414
+ baseUrl: settings.baseUrl,
415
+ apiKey: settings.apiKey,
416
+ model,
417
+ headers: settings.headers,
418
+ };
419
+ }
420
+ class OpenAICompatibleStructuredAdvisorAdapter {
421
+ options;
422
+ constructor(options = {}) {
423
+ this.options = options;
424
+ }
425
+ async runStructuredRound(args) {
426
+ const sessionHandle = buildSyntheticSessionHandle();
427
+ try {
428
+ const protocol = args.structuredJsonProtocol;
429
+ if (!protocol || protocol.protocol !== 'neal-json-block-v1') {
430
+ throw createOpenAICompatibleProviderError({
431
+ message: `OpenAI-compatible ${args.label} rounds require the neal-json-block-v1 structured JSON protocol; ` +
432
+ 'this provider does not support provider-native structured output schemas.',
433
+ sessionHandle,
434
+ kind: 'provider_failed',
435
+ retryable: false,
436
+ });
437
+ }
438
+ const resolveSettings = this.options.resolveSettings ?? getOpenAICompatibleSettings;
439
+ const settings = resolveRoundSettings({
440
+ cwd: args.cwd,
441
+ roleModel: this.options.model ?? null,
442
+ roundModel: args.model ?? null,
443
+ label: args.label,
444
+ resolveSettings,
445
+ });
446
+ const createModel = this.options.createModel ?? createDefaultOpenAICompatibleModel;
447
+ const model = createModel({
448
+ baseUrl: settings.baseUrl,
449
+ apiKey: settings.apiKey,
450
+ headers: settings.headers,
451
+ model: settings.model,
452
+ fetch: createInterceptingFetch({ label: args.label, sessionHandle }),
453
+ });
454
+ await emitProviderEvent(args.events, {
455
+ type: 'session_started',
456
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
457
+ role: 'structured-advisor',
458
+ label: args.label,
459
+ sessionHandle,
460
+ });
461
+ const result = await runStructuredJsonProtocol({
462
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
463
+ role: 'structured-advisor',
464
+ label: args.label,
465
+ protocol,
466
+ prompt: args.prompt,
467
+ events: args.events,
468
+ initialSessionHandle: sessionHandle,
469
+ runInitial: async (prompt) => ({
470
+ assistantText: await this.runChatTurnWithRetry({
471
+ model,
472
+ prompt,
473
+ roundArgs: args,
474
+ sessionHandle,
475
+ // Only the primary turn is wired to external cancellation; repair
476
+ // turns are short prompt-only turns (matching other adapters).
477
+ signal: args.signal,
478
+ }),
479
+ sessionHandle,
480
+ }),
481
+ runRepair: async (prompt) => ({
482
+ assistantText: await this.runChatTurnWithRetry({
483
+ model,
484
+ prompt,
485
+ roundArgs: args,
486
+ sessionHandle,
487
+ }),
488
+ sessionHandle,
489
+ }),
490
+ createProviderError: (errorArgs) => createOpenAICompatibleProviderError({
491
+ message: errorArgs.message,
492
+ sessionHandle: errorArgs.sessionHandle,
493
+ kind: errorArgs.kind,
494
+ retryable: false,
495
+ cause: errorArgs.cause,
496
+ }),
497
+ });
498
+ return {
499
+ // Never persist a resumable handle for this provider.
500
+ sessionHandle: null,
501
+ structured: result.structured,
502
+ };
503
+ }
504
+ catch (error) {
505
+ const providerError = normalizeOpenAICompatibleError(error, {
506
+ label: args.label,
507
+ sessionHandle,
508
+ callerSignal: args.signal,
509
+ });
510
+ await emitProviderEvent(args.events, {
511
+ type: 'provider_error',
512
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
513
+ role: 'structured-advisor',
514
+ label: args.label,
515
+ sessionHandle: providerError.sessionHandle ?? sessionHandle,
516
+ message: providerError.message,
517
+ errorKind: providerError.kind,
518
+ });
519
+ throw providerError;
520
+ }
521
+ }
522
+ /**
523
+ * One chat turn with the bounded transient-retry loop: emits
524
+ * `turn_started`, makes a single tool-less `generateText` call
525
+ * (`temperature: 0`, `maxRetries: 0`, abort/inactivity signal), applies
526
+ * the reasoning-text fallback, then emits `assistant_text`,
527
+ * `turn_completed`, and `usage_reported` (the latter only when the
528
+ * response body actually carried usage, observed structurally via the AI
529
+ * SDK's `usage.raw` passthrough — preserving the bespoke adapter's
530
+ * usage-is-opportunistic event behavior). Transient failures retry up to
531
+ * `args.apiRetryLimit` times with the standard backoff and `api_retry`
532
+ * event shape; a caller abort is non-retryable and never burns retry
533
+ * budget.
534
+ */
535
+ async runChatTurnWithRetry(callArgs) {
536
+ const { roundArgs } = callArgs;
537
+ const sleep = this.options.sleep ?? defaultSleep;
538
+ const apiRetryLimit = roundArgs.apiRetryLimit;
539
+ const base = {
540
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
541
+ role: 'structured-advisor',
542
+ label: roundArgs.label,
543
+ sessionHandle: callArgs.sessionHandle,
544
+ };
545
+ let apiRetryCount = 0;
546
+ while (true) {
547
+ try {
548
+ await emitProviderEvent(roundArgs.events, { type: 'turn_started', ...base });
549
+ const result = await generateText({
550
+ model: callArgs.model,
551
+ prompt: callArgs.prompt,
552
+ temperature: 0,
553
+ maxRetries: 0,
554
+ abortSignal: composeAbortSignal(callArgs.signal, roundArgs.inactivityTimeoutMs),
555
+ });
556
+ // Reasoning-model fallback: the AI SDK surfaces `message.reasoning`
557
+ // and `message.reasoning_content` as reasoningText. The intercepting
558
+ // fetch already rejected bodies with no assistant text in any field,
559
+ // so this guard is defense in depth for non-body-shaped gaps.
560
+ const assistantText = result.text.trim().length > 0 ? result.text : (result.reasoningText ?? '');
561
+ if (assistantText.trim().length === 0) {
562
+ throw createOpenAICompatibleProviderError({
563
+ message: `OpenAI-compatible ${roundArgs.label} response did not include assistant text ` +
564
+ '(after the reasoning-text fallback).',
565
+ sessionHandle: callArgs.sessionHandle,
566
+ kind: 'api_error',
567
+ retryable: true,
568
+ });
569
+ }
570
+ await emitProviderEvent(roundArgs.events, {
571
+ type: 'assistant_text',
572
+ ...base,
573
+ text: assistantText,
574
+ });
575
+ // usage.raw is the AI SDK's passthrough of the response body's usage
576
+ // object; it is undefined when the body carried no usage (structural
577
+ // marker, matching the bespoke adapter's "only report usage when the
578
+ // gateway provided it" behavior).
579
+ const hasUsage = result.usage.raw !== undefined;
580
+ await emitProviderEvent(roundArgs.events, {
581
+ type: 'turn_completed',
582
+ ...base,
583
+ ...(hasUsage ? { usage: result.usage } : {}),
584
+ });
585
+ if (hasUsage) {
586
+ await emitProviderEvent(roundArgs.events, {
587
+ type: 'usage_reported',
588
+ ...base,
589
+ usage: result.usage,
590
+ });
591
+ }
592
+ return assistantText;
593
+ }
594
+ catch (error) {
595
+ const providerError = normalizeOpenAICompatibleError(error, {
596
+ label: roundArgs.label,
597
+ sessionHandle: callArgs.sessionHandle,
598
+ callerSignal: callArgs.signal,
599
+ });
600
+ // Caller cancellation never burns retry budget: a caller-signal
601
+ // abort is non-retryable and the aborted check is defensive depth.
602
+ if (providerError.retryable && apiRetryCount < apiRetryLimit && !callArgs.signal?.aborted) {
603
+ apiRetryCount += 1;
604
+ await emitProviderEvent(roundArgs.events, {
605
+ type: 'tool_progress',
606
+ ...base,
607
+ toolName: 'api_retry',
608
+ message: `transient API failure; retrying (${apiRetryCount}/${apiRetryLimit})`,
609
+ isError: true,
610
+ providerData: {
611
+ retryCount: apiRetryCount,
612
+ retryLimit: apiRetryLimit,
613
+ message: providerError.message,
614
+ },
615
+ });
616
+ await sleep(getApiRetryDelayMs(apiRetryCount));
617
+ continue;
618
+ }
619
+ throw providerError;
620
+ }
621
+ }
622
+ }
623
+ }
624
+ export function createOpenAICompatibleStructuredAdvisorAdapter(options = {}) {
625
+ return new OpenAICompatibleStructuredAdvisorAdapter(options);
626
+ }
627
+ export const openAICompatibleProviderDefinition = {
628
+ id: OPENAI_COMPATIBLE_PROVIDER_ID,
629
+ displayName: 'OpenAI-Compatible',
630
+ capabilities: {
631
+ coder: {
632
+ supported: false,
633
+ toolAccess: { read: false, write: false, shell: false },
634
+ supportsSessionResume: false,
635
+ supportsModelOverride: true,
636
+ supportsStructuredOutput: false,
637
+ usageReporting: 'none',
638
+ },
639
+ 'structured-advisor': {
640
+ supported: true,
641
+ toolAccess: { read: false, write: false, shell: false },
642
+ supportsSessionResume: false,
643
+ supportsModelOverride: true,
644
+ supportsStructuredOutput: true,
645
+ usageReporting: 'opportunistic',
646
+ },
647
+ },
648
+ createStructuredAdvisorAdapter: createOpenAICompatibleStructuredAdvisorAdapter,
649
+ };
650
+ export const openAICompatibleProviderTestHooks = {
651
+ createDefaultOpenAICompatibleModel,
652
+ normalizeOpenAICompatibleError,
653
+ createStructuredAdvisorAdapterWithInjection: (injection, options) => new OpenAICompatibleStructuredAdvisorAdapter({ ...options, ...injection }),
654
+ };