@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.
- package/LICENSE +21 -0
- package/README.md +527 -0
- package/SECURITY.md +91 -0
- package/config.yml +104 -0
- package/dist/neal/activity-footer.js +177 -0
- package/dist/neal/activity-reporting.js +1 -0
- package/dist/neal/adjudicator/artifacts.js +58 -0
- package/dist/neal/adjudicator/blocked-adjudicator.js +223 -0
- package/dist/neal/adjudicator/contracts.js +139 -0
- package/dist/neal/adjudicator/execute.js +611 -0
- package/dist/neal/adjudicator/final-completion.js +104 -0
- package/dist/neal/adjudicator/planning.js +145 -0
- package/dist/neal/adjudicator/specs.js +453 -0
- package/dist/neal/agents/prompts.js +120 -0
- package/dist/neal/agents/rounds.js +706 -0
- package/dist/neal/agents/schemas.js +832 -0
- package/dist/neal/agents/structured-coder.js +82 -0
- package/dist/neal/agents/structured-json.js +528 -0
- package/dist/neal/agents.js +4 -0
- package/dist/neal/atomic-write.js +18 -0
- package/dist/neal/blocked-guidance.js +406 -0
- package/dist/neal/cli.js +471 -0
- package/dist/neal/commands/check.js +401 -0
- package/dist/neal/commands/compat.js +807 -0
- package/dist/neal/commands/interactive-activity.js +57 -0
- package/dist/neal/commands/new-run.js +79 -0
- package/dist/neal/commands/plan-and-execute.js +44 -0
- package/dist/neal/commands/recovery-guidance.js +217 -0
- package/dist/neal/commands/resume-run.js +395 -0
- package/dist/neal/commands/review.js +21 -0
- package/dist/neal/commands/runtime.js +557 -0
- package/dist/neal/commands/setup.js +596 -0
- package/dist/neal/commands/squash.js +113 -0
- package/dist/neal/commands/status.js +33 -0
- package/dist/neal/commands/writer-exit-codes.js +42 -0
- package/dist/neal/commit-message.js +17 -0
- package/dist/neal/config.js +432 -0
- package/dist/neal/context/artifacts.js +140 -0
- package/dist/neal/context/context.js +324 -0
- package/dist/neal/context/inline-review-context.js +131 -0
- package/dist/neal/context/reviewer-context.js +166 -0
- package/dist/neal/context/shared.js +117 -0
- package/dist/neal/context/types.js +1 -0
- package/dist/neal/diagnostic.js +208 -0
- package/dist/neal/execute-finalization.js +5 -0
- package/dist/neal/final-completion-review.js +188 -0
- package/dist/neal/final-completion.js +229 -0
- package/dist/neal/git.js +339 -0
- package/dist/neal/index.js +135 -0
- package/dist/neal/interactive-controls.js +85 -0
- package/dist/neal/logger.js +102 -0
- package/dist/neal/manual-gates.js +121 -0
- package/dist/neal/orchestrator/artifacts.js +70 -0
- package/dist/neal/orchestrator/completion.js +531 -0
- package/dist/neal/orchestrator/failures.js +31 -0
- package/dist/neal/orchestrator/notifications.js +175 -0
- package/dist/neal/orchestrator/phases/coder.js +516 -0
- package/dist/neal/orchestrator/phases/planning.js +540 -0
- package/dist/neal/orchestrator/phases/recovery.js +798 -0
- package/dist/neal/orchestrator/phases/review.js +136 -0
- package/dist/neal/orchestrator/phases/shared.js +279 -0
- package/dist/neal/orchestrator/run-loop.js +113 -0
- package/dist/neal/orchestrator/split-plan.js +235 -0
- package/dist/neal/orchestrator/transitions.js +309 -0
- package/dist/neal/orchestrator.js +215 -0
- package/dist/neal/phase-display.js +27 -0
- package/dist/neal/plan-doc.js +154 -0
- package/dist/neal/plan-queue.js +1092 -0
- package/dist/neal/plan-refinement.js +39 -0
- package/dist/neal/plan-validation.js +525 -0
- package/dist/neal/progress.js +237 -0
- package/dist/neal/prompts/assert-builder.js +13 -0
- package/dist/neal/prompts/execute.js +290 -0
- package/dist/neal/prompts/guidance.js +70 -0
- package/dist/neal/prompts/planning.js +313 -0
- package/dist/neal/prompts/review-doctrine.js +142 -0
- package/dist/neal/prompts/shared.js +101 -0
- package/dist/neal/prompts/specialized.js +212 -0
- package/dist/neal/prompts/specs.js +572 -0
- package/dist/neal/providers/anthropic-claude.js +1599 -0
- package/dist/neal/providers/detection.js +139 -0
- package/dist/neal/providers/generic-agentic-tools.js +586 -0
- package/dist/neal/providers/generic-agentic.js +1238 -0
- package/dist/neal/providers/liveness.js +151 -0
- package/dist/neal/providers/openai-codex.js +1014 -0
- package/dist/neal/providers/openai-compatible.js +654 -0
- package/dist/neal/providers/registry.js +389 -0
- package/dist/neal/providers/telemetry.js +208 -0
- package/dist/neal/providers/types.js +21 -0
- package/dist/neal/recovery-artifacts.js +50 -0
- package/dist/neal/resume-decision.js +220 -0
- package/dist/neal/resume-planner.js +265 -0
- package/dist/neal/retrospective.js +391 -0
- package/dist/neal/review-debt.js +18 -0
- package/dist/neal/review-findings/artifacts.js +173 -0
- package/dist/neal/review-findings/prompts.js +172 -0
- package/dist/neal/review-findings/provider.js +330 -0
- package/dist/neal/review-findings/run.js +373 -0
- package/dist/neal/review-findings/types.js +1 -0
- package/dist/neal/review-mode.js +67 -0
- package/dist/neal/review.js +137 -0
- package/dist/neal/run-lock.js +334 -0
- package/dist/neal/run-metrics.js +355 -0
- package/dist/neal/run-narrative-types.js +1 -0
- package/dist/neal/run-narrative.js +1374 -0
- package/dist/neal/run-registry.js +218 -0
- package/dist/neal/run-status.js +25 -0
- package/dist/neal/scopes.js +451 -0
- package/dist/neal/sensitive-text.js +8 -0
- package/dist/neal/squash-message.js +379 -0
- package/dist/neal/squash.js +591 -0
- package/dist/neal/state-invariants.js +496 -0
- package/dist/neal/state-views.js +344 -0
- package/dist/neal/state.js +887 -0
- package/dist/neal/status-footer.js +258 -0
- package/dist/neal/status.js +1260 -0
- package/dist/neal/storage-paths.js +57 -0
- package/dist/neal/support.js +58 -0
- package/dist/neal/terminal-narrator.js +435 -0
- package/dist/neal/types.js +1 -0
- package/dist/neal/verification-events.js +81 -0
- package/dist/neal/version.js +37 -0
- package/dist/neal/worktree-status.js +137 -0
- package/dist/notifier.js +44 -0
- package/docs/ADJUDICATOR_INVENTORY.md +310 -0
- package/docs/PROMPT_SPECS.md +266 -0
- package/docs/README.md +22 -0
- package/docs/architecture.md +113 -0
- package/docs/assets/neal-execution-flow.png +0 -0
- package/docs/automation.md +65 -0
- package/docs/comparison.md +105 -0
- package/docs/compat.md +269 -0
- package/docs/compatible-models.md +135 -0
- package/docs/demo.md +55 -0
- package/docs/maintenance.md +64 -0
- package/docs/plan-format.md +213 -0
- package/docs/providers.md +751 -0
- package/docs/release.md +147 -0
- package/docs/state-machine.md +266 -0
- package/docs/storage.md +207 -0
- package/docs/troubleshooting.md +152 -0
- package/examples/compat/add-edit-verify/PLAN.md +29 -0
- package/examples/compat/add-edit-verify/broken.diff +8 -0
- package/examples/compat/add-edit-verify/good.diff +8 -0
- package/examples/compat/add-edit-verify/package.json +5 -0
- package/examples/compat/add-edit-verify/src/add.js +2 -0
- package/examples/compat/add-edit-verify/test/add.test.js +9 -0
- package/examples/compat/is-even-add-test/PLAN.md +30 -0
- package/examples/compat/is-even-add-test/broken.diff +11 -0
- package/examples/compat/is-even-add-test/good.diff +11 -0
- package/examples/compat/is-even-add-test/package.json +5 -0
- package/examples/compat/is-even-add-test/src/is-even.js +3 -0
- package/examples/compat/is-even-add-test/test/is-even.test.js +9 -0
- package/examples/compat/manifest.json +60 -0
- package/examples/compat/plan-greeting/ISSUE.md +25 -0
- package/examples/compat/plan-greeting/package.json +5 -0
- package/examples/compat/plan-greeting/src/greet.js +2 -0
- package/examples/compat/plan-greeting/test/greet.test.js +8 -0
- package/examples/compat/reverse-grep-edit/PLAN.md +32 -0
- package/examples/compat/reverse-grep-edit/broken.diff +12 -0
- package/examples/compat/reverse-grep-edit/good.diff +12 -0
- package/examples/compat/reverse-grep-edit/package.json +5 -0
- package/examples/compat/reverse-grep-edit/src/strings.js +10 -0
- package/examples/compat/reverse-grep-edit/test/strings.test.js +15 -0
- package/examples/issue-triage-js/PLAN.md +83 -0
- package/examples/issue-triage-js/README.md +76 -0
- package/examples/issue-triage-js/package.json +9 -0
- package/examples/issue-triage-js/src/issue-triage.js +87 -0
- package/examples/issue-triage-js/test/issue-triage.test.js +107 -0
- 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
|
+
};
|