mixdog 1.0.12 → 1.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 (256) hide show
  1. package/README.md +75 -181
  2. package/package.json +4 -2
  3. package/scripts/build-shipped-history.mjs +131 -0
  4. package/scripts/lib/run-node-tests.mjs +235 -13
  5. package/scripts/lib/test-failure-records.mjs +5 -0
  6. package/scripts/lib/test-failure-summary.mjs +9 -3
  7. package/scripts/lib/test-spawn-ledger.mjs +19 -0
  8. package/scripts/prune-embedding-runtime.mjs +9 -5
  9. package/scripts/test-direct.mjs +16 -1
  10. package/src/agents/advisor/AGENT.md +38 -0
  11. package/src/agents/advisor/agent.json +6 -0
  12. package/src/agents/reviewer/AGENT.md +5 -2
  13. package/src/agents/reviewer/agent.json +1 -1
  14. package/src/defaults/shipped-history.json +381 -0
  15. package/src/defaults/skills/goal-management/SKILL.md +11 -5
  16. package/src/defaults/skills/setup/SKILL.md +5 -8
  17. package/src/defaults/skills/setup/references/actions.md +61 -31
  18. package/src/defaults/skills/setup/references/surfaces.md +12 -10
  19. package/src/lib/rules-builder.cjs +1 -1
  20. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +3 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +4 -0
  22. package/src/runtime/agent/orchestrator/config-presets.d.mts +1 -0
  23. package/src/runtime/agent/orchestrator/config-presets.mjs +44 -0
  24. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +9 -1
  25. package/src/runtime/agent/orchestrator/config.mjs +102 -21
  26. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +77 -26
  27. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +44 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +2 -1
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +11 -2
  30. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-login.mjs +2 -0
  31. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +3 -3
  32. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +388 -0
  33. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +92 -109
  34. package/src/runtime/agent/orchestrator/providers/custom-reasoning.mjs +107 -0
  35. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +51 -9
  36. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +2 -1
  37. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +14 -9
  38. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +51 -11
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +6 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +7 -5
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +12 -0
  42. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +27 -7
  43. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +12 -2
  44. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +2 -1
  46. package/src/runtime/agent/orchestrator/providers/quota-reading.mjs +70 -0
  47. package/src/runtime/agent/orchestrator/providers/registry.mjs +38 -3
  48. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +19 -35
  49. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +5 -0
  50. package/src/runtime/agent/orchestrator/runtime-core/goal-mutations.mjs +0 -17
  51. package/src/runtime/agent/orchestrator/runtime-core/goal-state.mjs +0 -3
  52. package/src/runtime/agent/orchestrator/runtime-core/goal-store.mjs +0 -1
  53. package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +1 -1
  55. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +1 -2
  56. package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +18 -4
  57. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +7 -1
  58. package/src/runtime/agent/orchestrator/runtime-core/session-route-policy.mjs +16 -0
  59. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +7 -7
  62. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +6 -0
  63. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +28 -3
  64. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +24 -11
  65. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +2 -6
  66. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +63 -3
  67. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +4 -4
  68. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +1 -0
  69. package/src/runtime/agent/orchestrator/stall-policy.mjs +0 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +19 -20
  72. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -11
  73. package/src/runtime/agent/orchestrator/tools/builtin/file-version-test-helpers.mjs +11 -0
  74. package/src/runtime/agent/orchestrator/tools/builtin/file-version.mjs +13 -0
  75. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +8 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-task-output.mjs +25 -12
  77. package/src/runtime/agent/orchestrator/tools/builtin/raw-content-publication.mjs +17 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +136 -76
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +15 -17
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -2
  81. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +11 -10
  82. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -3
  83. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +30 -16
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +4 -11
  85. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +7 -3
  86. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-validation.mjs +16 -19
  87. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  88. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background.mjs +4 -1
  89. package/src/runtime/agent/orchestrator/tools/lib/shell-run-settle.mjs +14 -4
  90. package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +58 -8
  91. package/src/runtime/agent/orchestrator/tools/patch/content-guard.mjs +47 -0
  92. package/src/runtime/agent/orchestrator/tools/patch/delete-quarantine.mjs +155 -0
  93. package/src/runtime/agent/orchestrator/tools/patch/dispatch/native-proofs.mjs +6 -2
  94. package/src/runtime/agent/orchestrator/tools/patch/dispatch/native-snapshots.mjs +6 -2
  95. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +19 -16
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +13 -44
  97. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  98. package/src/runtime/agent/orchestrator/tools/patch/post-patch-excerpts.mjs +5 -2
  99. package/src/runtime/agent/orchestrator/tools/patch/replay-capture.mjs +47 -7
  100. package/src/runtime/agent/orchestrator/tools/patch/rollback-state.mjs +72 -8
  101. package/src/runtime/agent/orchestrator/tools/patch/section-coalesce.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/patch/sequence/run.mjs +9 -1
  103. package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +9 -1
  104. package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +6 -6
  105. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +16 -4
  106. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -14
  107. package/src/runtime/agent/orchestrator/tools/patch/wave.mjs +42 -4
  108. package/src/runtime/agent/orchestrator/tools/shell-codepage.mjs +296 -0
  109. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +75 -24
  110. package/src/runtime/channels/lib/config.mjs +3 -8
  111. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +7 -3
  112. package/src/{tui → runtime/channels}/lib/voice-setup.mjs +38 -22
  113. package/src/runtime/channels/lib/webhook/request-handler.mjs +42 -8
  114. package/src/runtime/computer-bridge/actions.mjs +2 -0
  115. package/src/runtime/effort-judge/judge-client.mjs +12 -3
  116. package/src/runtime/effort-judge/model-manifest.json +7 -7
  117. package/src/runtime/effort-judge/previous-human-request.mjs +162 -0
  118. package/src/runtime/effort-judge/verified-context-input.mjs +10 -0
  119. package/src/runtime/memory/lib/embedding-provider.mjs +12 -0
  120. package/src/runtime/memory/lib/http-router/session-routes.mjs +2 -1
  121. package/src/runtime/memory/lib/memory-config-flags.mjs +3 -2
  122. package/src/runtime/memory/lib/memory-embed.mjs +5 -0
  123. package/src/runtime/shared/agent-route-config.mjs +50 -2
  124. package/src/runtime/shared/bounded-file-read.mjs +166 -0
  125. package/src/runtime/shared/builtin-grandfathering.mjs +37 -0
  126. package/src/runtime/shared/config.mjs +24 -4
  127. package/src/runtime/shared/defaults-separation.mjs +263 -0
  128. package/src/runtime/shared/llm/anthropic-effort.mjs +4 -0
  129. package/src/runtime/shared/llm/model-catalog.mjs +23 -0
  130. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +105 -1
  131. package/src/runtime/shared/llm/usage-ledger-worker.mjs +2 -1
  132. package/src/runtime/shared/llm/usage-ledger.mjs +72 -5
  133. package/src/runtime/shared/onnx-runtime-support.mjs +6 -0
  134. package/src/runtime/shared/orchestration.mjs +14 -6
  135. package/src/runtime/shared/projects.mjs +65 -48
  136. package/src/runtime/shared/provider-accounts.mjs +47 -6
  137. package/src/runtime/shared/quota-resume-store.mjs +255 -0
  138. package/src/runtime/shared/shipped-definitions.mjs +275 -0
  139. package/src/runtime/shared/tool-card-model.golden.json +3 -3
  140. package/src/runtime/shared/tool-result-summary.mjs +86 -15
  141. package/src/runtime/shared/transcript-metadata.mjs +2 -0
  142. package/src/runtime/shared/turn-review-reply.mjs +2 -0
  143. package/src/runtime/shared/turn-snapshot.mjs +65 -6
  144. package/src/runtime/shared/turn-worktree-snapshot.mjs +202 -22
  145. package/src/session-runtime/boot/apis.mjs +19 -0
  146. package/src/session-runtime/boot/config.mjs +4 -0
  147. package/src/session-runtime/boot/facade.mjs +4 -1
  148. package/src/session-runtime/boot/infrastructure.mjs +21 -2
  149. package/src/session-runtime/boot/session.mjs +3 -0
  150. package/src/session-runtime/channel-config-api.mjs +5 -0
  151. package/src/session-runtime/config-lifecycle/config-writers.mjs +3 -0
  152. package/src/session-runtime/config-lifecycle.mjs +31 -6
  153. package/src/session-runtime/goal-facade-api.mjs +4 -0
  154. package/src/session-runtime/lifecycle/inheritance.mjs +28 -6
  155. package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +3 -0
  156. package/src/session-runtime/lifecycle/session-switch/resume.mjs +7 -1
  157. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +6 -0
  158. package/src/session-runtime/lifecycle/teardown/session-close.mjs +14 -2
  159. package/src/session-runtime/media-api.mjs +32 -22
  160. package/src/session-runtime/model-recency.mjs +14 -0
  161. package/src/session-runtime/model-route/route-tuning.mjs +3 -5
  162. package/src/session-runtime/model-route/set-route.mjs +49 -21
  163. package/src/session-runtime/model-route-api.mjs +2 -1
  164. package/src/session-runtime/notification-bus.mjs +31 -6
  165. package/src/session-runtime/provider-auth/login.mjs +6 -0
  166. package/src/session-runtime/provider-models/catalog-load.mjs +3 -1
  167. package/src/session-runtime/provider-models/row-hydration.mjs +15 -14
  168. package/src/session-runtime/quick-model-rows/model-meta.mjs +26 -0
  169. package/src/session-runtime/quick-model-rows/provider-rows.mjs +40 -14
  170. package/src/session-runtime/resource-api.mjs +8 -1
  171. package/src/session-runtime/runtime-core.mjs +4 -0
  172. package/src/session-runtime/runtime-facade.mjs +20 -1
  173. package/src/session-runtime/runtime-review-api.mjs +12 -1
  174. package/src/session-runtime/services/agent-tool/helpers.mjs +4 -0
  175. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +10 -15
  176. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +11 -3
  177. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +2 -0
  178. package/src/session-runtime/services/channel-admin.mjs +87 -13
  179. package/src/session-runtime/services/provider-admin.mjs +256 -63
  180. package/src/session-runtime/services/seeds.mjs +73 -142
  181. package/src/session-runtime/services/usage-dashboard-model.mjs +29 -0
  182. package/src/session-runtime/services/usage-dashboard.mjs +7 -0
  183. package/src/session-runtime/session-address.mjs +125 -0
  184. package/src/session-runtime/session-create.mjs +57 -2
  185. package/src/session-runtime/settings-builtin-tools-api.mjs +8 -0
  186. package/src/session-runtime/settings-compaction-api.mjs +13 -0
  187. package/src/session-runtime/setup-tool/executor.mjs +92 -40
  188. package/src/session-runtime/setup-tool/extended-actions.mjs +64 -5
  189. package/src/session-runtime/setup-tool/settings-contract.mjs +44 -1
  190. package/src/session-runtime/setup-tool/tool-defs.mjs +12 -7
  191. package/src/session-runtime/tool-surface.mjs +16 -9
  192. package/src/session-runtime/turn/session-ops.mjs +3 -0
  193. package/src/session-runtime/turn/share-ops.mjs +4 -1
  194. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +6 -0
  195. package/src/session-runtime/usage-stats-api.mjs +15 -0
  196. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +31 -5
  197. package/src/session-runtime/workflow-agents-api/agent-route.mjs +4 -0
  198. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +27 -7
  199. package/src/standalone/daemon-boot-coordinator.mjs +10 -0
  200. package/src/standalone/daemon-drain-policy.mjs +9 -0
  201. package/src/standalone/daemon-stored-session-views.mjs +7 -8
  202. package/src/standalone/daemon.mjs +21 -5
  203. package/src/standalone/local-session-runtime.mjs +11 -0
  204. package/src/standalone/session-client.mjs +52 -7
  205. package/src/standalone/session-protocol.mjs +8 -0
  206. package/src/standalone/session-service/agent-tree/agent-child.mjs +2 -0
  207. package/src/standalone/session-service/entries/lifecycle.mjs +2 -0
  208. package/src/standalone/session-service/entries/session-loads.mjs +9 -0
  209. package/src/standalone/session-service/projection/revisions.mjs +28 -4
  210. package/src/standalone/session-service/projection.mjs +1 -0
  211. package/src/standalone/session-service/quota-resume.mjs +140 -0
  212. package/src/standalone/session-service/session-calls/session-action.mjs +2 -2
  213. package/src/standalone/session-service/session-calls.mjs +10 -2
  214. package/src/standalone/session-service/stored-reader.mjs +26 -1
  215. package/src/standalone/session-service.mjs +21 -0
  216. package/src/standalone/session-transport/routes.mjs +2 -0
  217. package/src/tui/app/doctor.mjs +7 -11
  218. package/src/tui/app/maintenance-pickers/auto-clear-picker.mjs +3 -5
  219. package/src/tui/app/settings-picker/settings-rows.mjs +4 -6
  220. package/src/tui/app/settings-picker/settings-toggles/store-toggles.mjs +1 -1
  221. package/src/tui/app/slash-dispatch/route-commands.mjs +3 -2
  222. package/src/tui/components/ContextPanel.jsx +5 -1
  223. package/src/tui/components/Spinner.jsx +34 -319
  224. package/src/tui/components/SpinnerByline.jsx +256 -0
  225. package/src/tui/components/use-spinner-progress.mjs +113 -0
  226. package/src/tui/dist/index.mjs +15540 -14452
  227. package/src/tui/session/agent-job-feed/execution-delivery.mjs +13 -1
  228. package/src/tui/session/agent-job-feed/notification-router.mjs +3 -0
  229. package/src/tui/session/agent-job-feed/pending-resume.mjs +14 -1
  230. package/src/tui/session/agent-job-feed.mjs +4 -0
  231. package/src/tui/session/context-state.mjs +13 -1
  232. package/src/tui/session/initial-state.mjs +6 -0
  233. package/src/tui/session/quota-resume.mjs +263 -0
  234. package/src/tui/session/session-api/intake/abort.mjs +39 -35
  235. package/src/tui/session/session-api/intake/submission.mjs +13 -21
  236. package/src/tui/session/session-api/intake.mjs +3 -3
  237. package/src/tui/session/session-api/integrations.mjs +5 -1
  238. package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
  239. package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -2
  240. package/src/tui/session/session-api/lifecycle/session-reset.mjs +4 -0
  241. package/src/tui/session/session-api/lifecycle.mjs +71 -2
  242. package/src/tui/session/session-api/media.mjs +2 -2
  243. package/src/tui/session/session-api/quota-resume.mjs +21 -0
  244. package/src/tui/session/session-api/routes.mjs +2 -0
  245. package/src/tui/session/session-api/settings.mjs +1 -0
  246. package/src/tui/session/session-api.mjs +2 -0
  247. package/src/tui/session/session-flow/auto-clear.mjs +14 -6
  248. package/src/tui/session/session-flow/drain.mjs +52 -5
  249. package/src/tui/session/session-flow/queue/take-entries.mjs +2 -3
  250. package/src/tui/session/session-flow/queue.mjs +3 -0
  251. package/src/tui/session/session-flow/steering.mjs +11 -0
  252. package/src/tui/session/session-flow.mjs +1 -0
  253. package/src/tui/session/tool-approval.mjs +7 -2
  254. package/src/tui/session/transcript-intake.mjs +1 -0
  255. package/src/tui/session/turn.mjs +40 -3
  256. package/src/vendor/statusline/bin/statusline-route.mjs +3 -2
@@ -4,120 +4,92 @@
4
4
  // runtime reuses the existing OpenAI-compat / Anthropic engines, constructed
5
5
  // under the custom id so model routes and provider identity stay `custom-…`.
6
6
 
7
+ import { effectiveCustomModelRows, normalizeCustomProviderConfig } from './custom-provider-config.mjs';
7
8
  import { ANTHROPIC_VERSION } from './lib/anthropic-models.mjs';
8
- import { assertSafeBaseURL } from './provider-base-url.mjs';
9
- import { listCompatModels } from './openai-compat-models.mjs';
9
+ import { enrichModels } from './model-catalog.mjs';
10
+ import { fetchCompatModelItems, listCompatModels } from './openai-compat-models.mjs';
10
11
 
11
- export const CUSTOM_PROVIDER_PROTOCOLS = Object.freeze(['openai-chat', 'openai-responses', 'anthropic']);
12
+ export {
13
+ CUSTOM_PROVIDER_PROTOCOLS,
14
+ CUSTOM_PROVIDER_SCHEMA_VERSION,
15
+ CUSTOM_REASONING_LEVELS,
16
+ assertCustomReasoningPatch,
17
+ canonicalReasoningLevel,
18
+ effectiveCustomModelRows,
19
+ normalizeCustomProviderConfig,
20
+ } from './custom-provider-config.mjs';
12
21
 
13
- function fail(message) {
14
- throw new Error(`[custom-provider] ${message}`);
15
- }
16
-
17
- function text(value) {
18
- return typeof value === 'string' ? value.trim() : '';
19
- }
20
-
21
- function positiveInt(value, label) {
22
- if (value === undefined || value === null || value === '') return undefined;
23
- const n = Number(value);
24
- if (!Number.isInteger(n) || n <= 0) fail(`${label} must be a positive integer`);
25
- return n;
26
- }
27
-
28
- function normalizeBaseURL(raw) {
29
- const value = text(raw);
30
- if (!value) fail('baseURL is required');
31
- let url;
32
- try {
33
- url = new URL(value);
34
- } catch {
35
- fail(`invalid baseURL: ${value}`);
36
- }
37
- if (url.protocol !== 'https:' && url.protocol !== 'http:') fail('baseURL must use http or https');
38
- if (url.username || url.password) fail('baseURL must not embed credentials');
39
- if (url.search || url.hash || /[?#]/.test(value)) fail('baseURL must not contain a query or hash');
40
- return assertSafeBaseURL(value.replace(/\/+$/, ''), 'custom');
41
- }
42
-
43
- function normalizeModels(raw, requireModels) {
44
- if (raw === undefined || raw === null) raw = [];
45
- if (!Array.isArray(raw)) fail('models must be an array');
46
- const seen = new Set();
47
- const models = raw.map((entry) => {
48
- const id = text(entry?.id);
49
- if (!id) fail('every model needs an id');
50
- if (seen.has(id)) fail(`duplicate model id: ${id}`);
51
- seen.add(id);
52
- const name = text(entry.name);
53
- const contextWindow = positiveInt(entry.contextWindow, `model ${id} contextWindow`);
54
- const maxOutputTokens = positiveInt(entry.maxOutputTokens, `model ${id} maxOutputTokens`);
55
- return {
56
- id,
57
- ...(name ? { name } : {}),
58
- ...(contextWindow ? { contextWindow } : {}),
59
- ...(maxOutputTokens ? { maxOutputTokens } : {}),
60
- };
61
- });
62
- if (requireModels && models.length === 0) fail('at least one model is required');
63
- return models;
64
- }
22
+ const DISCOVERY_TIMEOUT_MS = 10_000;
65
23
 
66
- export function normalizeCustomProviderConfig(input, { requireModels = true } = {}) {
67
- if (!input || typeof input !== 'object') fail('config must be an object');
68
- const name = text(input.name);
69
- if (!name) fail('name is required');
70
- const protocol = text(input.protocol);
71
- if (!protocol) fail('protocol is required');
72
- if (!CUSTOM_PROVIDER_PROTOCOLS.includes(protocol)) fail(`unsupported protocol: ${protocol}`);
73
- return {
74
- type: 'custom',
75
- name,
76
- protocol,
77
- baseURL: normalizeBaseURL(input.baseURL),
78
- models: normalizeModels(input.models, requireModels),
79
- enabled: true,
80
- };
24
+ // Discovery error safe to surface: the key is redacted, the HTTP status kept.
25
+ function sanitizeDiscoveryError(error, apiKey) {
26
+ let message = String(error?.message || error || 'model discovery failed');
27
+ if (apiKey) message = message.split(apiKey).join('[redacted]');
28
+ const out = new Error(message);
29
+ if (Number.isInteger(error?.status)) out.status = error.status;
30
+ return out;
81
31
  }
82
32
 
83
- function modelRow(id, m) {
84
- return {
85
- id: m.id,
86
- name: m.name || m.id,
87
- provider: id,
88
- contextWindow: m.contextWindow || 0,
89
- outputTokens: m.maxOutputTokens || null,
90
- };
91
- }
92
-
93
- // Configured models win; an empty list falls back to protocol discovery.
94
- function withConfiguredModels(Base, discover) {
33
+ // Discovery always runs; overrides layer on top. A failed discovery keeps the
34
+ // last successful discovered rows plus manual rows, and throws only when
35
+ // nothing is usable or the caller asks (`throwOnError`). The sanitized error
36
+ // of the latest attempt is kept in `customDiscoveryError` (null on success).
37
+ function withCustomModels(Base, discover) {
95
38
  return class extends Base {
96
- _customModels = [];
97
- async listModels() {
98
- if (!this._customModels.length) return discover(this);
99
- if ('_enrichedModels' in this) this._enrichedModels = this._customModels;
100
- return this._customModels.map((m) => ({ ...m }));
39
+ customConfig = null;
40
+ customDiscoveryError = null;
41
+ _customDiscovered = null;
42
+ _customApiKey = '';
43
+ _customKeyless = false;
44
+ _customRows() {
45
+ return effectiveCustomModelRows(this.customConfig, this._customDiscovered || [], { providerId: this.name });
46
+ }
47
+ _applyCustomRows(rows) {
48
+ this._enrichedModels = rows;
49
+ if (rows.some((row) => row.id === this.defaultModel && !row.hidden)) return;
50
+ const first = rows.find((row) => !row.hidden);
51
+ if (first) this.defaultModel = first.id;
52
+ }
53
+ async listModels({ signal = null, throwOnError = false } = {}) {
54
+ let failure = null;
55
+ try {
56
+ this._customDiscovered = await discover(this, signal);
57
+ this.customDiscoveryError = null;
58
+ } catch (error) {
59
+ if (signal?.aborted) throw error;
60
+ failure = sanitizeDiscoveryError(error, this._customApiKey);
61
+ this.customDiscoveryError = failure;
62
+ }
63
+ const rows = this._customRows();
64
+ this._applyCustomRows(rows);
65
+ if (failure && (throwOnError || rows.length === 0)) throw failure;
66
+ return rows.map((row) => structuredClone(row));
101
67
  }
102
68
  async isAvailable() {
103
- return this._customModels.length ? true : super.isAvailable();
69
+ if (this._enrichedModels?.length) return true;
70
+ if (this._customKeyless && this.customConfig.protocol === 'anthropic') return true;
71
+ return super.isAvailable();
104
72
  }
105
73
  getCachedModelInfo(model) {
106
- return this._customModels.find((m) => m.id === model) || super.getCachedModelInfo?.(model) || null;
74
+ return this._enrichedModels?.find((row) => row.id === model) || null;
107
75
  }
108
76
  };
109
77
  }
110
78
 
111
- async function discoverAnthropicModels(provider) {
79
+ async function discoverAnthropicModels(provider, signal) {
80
+ const timeout = AbortSignal.timeout(DISCOVERY_TIMEOUT_MS);
112
81
  const res = await fetch(`${provider.config.baseURL}/v1/models`, {
113
82
  method: 'GET',
114
- signal: AbortSignal.timeout(10_000),
115
- headers: { 'x-api-key': provider.apiKey || '', 'anthropic-version': ANTHROPIC_VERSION },
83
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
84
+ headers: {
85
+ ...(provider._customKeyless ? {} : { 'x-api-key': provider.apiKey || '' }),
86
+ 'anthropic-version': ANTHROPIC_VERSION,
87
+ },
116
88
  });
117
89
  if (!res.ok) throw Object.assign(new Error(`${provider.name} models ${res.status}`), { status: res.status });
118
90
  const data = await res.json();
119
91
  const items = Array.isArray(data?.data) ? data.data : [];
120
- return items
92
+ const rows = items
121
93
  .filter((m) => m?.id)
122
94
  .map((m) => ({
123
95
  id: m.id,
@@ -126,40 +98,51 @@ async function discoverAnthropicModels(provider) {
126
98
  contextWindow: Number(m.max_input_tokens) > 0 ? Number(m.max_input_tokens) : 0,
127
99
  outputTokens: Number(m.max_tokens) > 0 ? Number(m.max_tokens) : null,
128
100
  }));
101
+ return enrichModels(rows);
129
102
  }
130
103
 
131
104
  export async function createCustomProvider(id, config) {
132
- const providerId = text(id);
133
- if (!/^custom-.+/.test(providerId)) fail(`invalid custom provider id: ${id}`);
134
- const cfg = normalizeCustomProviderConfig(config, { requireModels: false });
105
+ const providerId = typeof id === 'string' ? id.trim() : '';
106
+ if (!/^custom-.+/.test(providerId)) throw new Error(`[custom-provider] invalid custom provider id: ${id}`);
107
+ const cfg = normalizeCustomProviderConfig(config);
108
+ // `auth: 'none'` is authoritative: a supplied/injected key is ignored and
109
+ // `keyless` makes the engine omit every credential header.
110
+ const keyless = cfg.auth === 'none';
111
+ const apiKey = keyless ? '' : config.apiKey || '';
135
112
  const engine = {
136
113
  baseURL: cfg.baseURL,
137
- apiKey: config.apiKey || undefined,
114
+ apiKey: apiKey || undefined,
115
+ ...(keyless ? { keyless: true } : {}),
138
116
  ...(config.preconnect === false ? { preconnect: false } : {}),
139
117
  };
140
118
  let provider;
141
119
  if (cfg.protocol === 'anthropic') {
142
120
  const { AnthropicProvider } = await import('./anthropic.mjs');
143
- const Custom = withConfiguredModels(AnthropicProvider, discoverAnthropicModels);
121
+ const Custom = withCustomModels(AnthropicProvider, discoverAnthropicModels);
144
122
  // The Anthropic SDK appends /v1/messages itself; accept a /v1 base URL.
145
123
  provider = new Custom({ ...engine, name: providerId, baseURL: cfg.baseURL.replace(/\/v1$/i, '') });
146
124
  // An unknown third-party gateway behind the Anthropic wire: PDFs lower to text.
147
125
  provider.nativePdf = false;
148
126
  } else {
149
127
  const { OpenAICompatProvider } = await import('./openai-compat.mjs');
150
- const Base =
151
- cfg.protocol === 'openai-responses'
152
- ? class extends OpenAICompatProvider {
153
- _doSend(messages, model, tools, sendOpts) {
154
- return super._doSend(messages, model, tools, { ...(sendOpts || {}), compatWireApi: 'responses' });
155
- }
156
- }
157
- : OpenAICompatProvider;
158
- const Custom = withConfiguredModels(Base, (self) => listCompatModels(self, { throwOnError: true }));
128
+ const responses = cfg.protocol === 'openai-responses';
129
+ class CompatBase extends OpenAICompatProvider {
130
+ _fetchModelItems({ signal = null } = {}) {
131
+ return fetchCompatModelItems(this, { signal, keyless: this._customKeyless });
132
+ }
133
+ _doSend(messages, model, tools, sendOpts) {
134
+ if (!responses) return super._doSend(messages, model, tools, sendOpts);
135
+ return super._doSend(messages, model, tools, { ...(sendOpts || {}), compatWireApi: 'responses' });
136
+ }
137
+ }
138
+ const Custom = withCustomModels(CompatBase, (self, signal) =>
139
+ listCompatModels(self, { throwOnError: true, signal })
140
+ );
159
141
  provider = new Custom(providerId, engine);
160
- if (cfg.models.length) provider.defaultModel = cfg.models[0].id;
161
142
  }
162
- provider._customModels = cfg.models.map((m) => modelRow(providerId, m));
163
143
  provider.customConfig = cfg;
144
+ provider._customApiKey = apiKey;
145
+ provider._customKeyless = keyless;
146
+ provider._applyCustomRows(provider._customRows());
164
147
  return provider;
165
148
  }
@@ -0,0 +1,107 @@
1
+ // Per-model reasoning settings for user-defined (`custom-*`) providers. Pure:
2
+ // the effective model row (custom-provider getCachedModelInfo) carries the
3
+ // supported levels and the wire mapping; this module snaps the requested
4
+ // effort to a supported level and serializes it for the provider protocol.
5
+ // First-party providers never route through here.
6
+
7
+ export const CUSTOM_EFFORT_LEVELS = Object.freeze(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
8
+ const RANK = new Map(CUSTOM_EFFORT_LEVELS.map((level, index) => [level, index]));
9
+
10
+ export function isCustomProviderName(name) {
11
+ return String(name || '').startsWith('custom-');
12
+ }
13
+
14
+ /** One effort token in the custom vocabulary (`ultra` → `max`), or null. */
15
+ export function normalizeCustomEffort(value) {
16
+ const v = String(value ?? '')
17
+ .trim()
18
+ .toLowerCase();
19
+ const level = v === 'ultra' ? 'max' : v;
20
+ return RANK.has(level) ? level : null;
21
+ }
22
+
23
+ /**
24
+ * Supported levels declared on the row, ranked and deduplicated, or null when
25
+ * the row declares none (unknown). An explicit [] stays [] (no reasoning).
26
+ */
27
+ export function customEffortLevels(model) {
28
+ const option = Array.isArray(model?.reasoningOptions)
29
+ ? model.reasoningOptions.find(
30
+ (entry) =>
31
+ String(entry?.type || '')
32
+ .trim()
33
+ .toLowerCase() === 'effort'
34
+ )
35
+ : null;
36
+ let raw = null;
37
+ if (Array.isArray(option?.values)) raw = option.values;
38
+ else if (Array.isArray(model?.reasoningLevels)) raw = model.reasoningLevels;
39
+ if (!raw) return null;
40
+ const levels = new Set(raw.map(normalizeCustomEffort).filter(Boolean));
41
+ return CUSTOM_EFFORT_LEVELS.filter((level) => levels.has(level));
42
+ }
43
+
44
+ /**
45
+ * Snap a requested effort onto `levels`. Exact matches stay; `none` is kept
46
+ * only when supported; otherwise the strongest supported non-none level not
47
+ * stronger than the request. Never promotes; no eligible level → null.
48
+ */
49
+ export function snapCustomEffort(levels, effort) {
50
+ const requested = normalizeCustomEffort(effort);
51
+ if (!requested || !Array.isArray(levels) || levels.length === 0) return null;
52
+ const supported = levels.map(normalizeCustomEffort).filter(Boolean);
53
+ if (supported.includes(requested)) return requested;
54
+ if (requested === 'none') return null;
55
+ const ceiling = RANK.get(requested);
56
+ let best = null;
57
+ for (const level of supported) {
58
+ const rank = RANK.get(level);
59
+ if (level === 'none' || rank === undefined || rank > ceiling) continue;
60
+ if (best === null || rank > RANK.get(best)) best = level;
61
+ }
62
+ return best;
63
+ }
64
+
65
+ const EMPTY = Object.freeze({ level: null, fields: {}, thinking: null, replay: false });
66
+
67
+ /**
68
+ * @param {{ protocol: 'openai-chat'|'openai-responses'|'anthropic', model: object|null, effort: string|null|undefined }} input
69
+ * @returns {{ level: string|null, fields: object, thinking: 'on'|'off'|null, replay: boolean }}
70
+ */
71
+ export function resolveCustomReasoning({ protocol, model, effort } = {}) {
72
+ const custom = model?.customReasoning || {};
73
+ const wireFormat = custom.wireFormat === 'deepseek' ? 'deepseek' : 'standard';
74
+ // DeepSeek's `thinking` extension exists only on the Chat wire.
75
+ if (wireFormat === 'deepseek' && protocol !== 'openai-chat') return { ...EMPTY, fields: {} };
76
+ const level = snapCustomEffort(customEffortLevels(model), effort);
77
+ let thinking = null;
78
+ if (level !== null) thinking = level === 'none' ? 'off' : 'on';
79
+ const mapped = level === null ? null : custom.wireValues?.[level];
80
+ const value = typeof mapped === 'string' && mapped.trim() ? mapped.trim() : level;
81
+ let fields = {};
82
+ if (level !== null) {
83
+ if (protocol === 'openai-chat') {
84
+ if (wireFormat === 'deepseek') {
85
+ fields =
86
+ level === 'none'
87
+ ? { thinking: { type: 'disabled' } }
88
+ : { thinking: { type: 'enabled' }, reasoning_effort: value };
89
+ } else {
90
+ fields = { reasoning_effort: value };
91
+ }
92
+ } else if (protocol === 'openai-responses') {
93
+ fields = { reasoning: { effort: value } };
94
+ } else if (protocol === 'anthropic') {
95
+ fields =
96
+ level === 'none'
97
+ ? { thinking: { type: 'disabled' } }
98
+ : { thinking: { type: 'adaptive' }, output_config: { effort: value } };
99
+ }
100
+ }
101
+ // An explicit replay flag wins over the catalog's reasoning_content field.
102
+ const replayFlag =
103
+ typeof custom.replay === 'boolean' ? custom.replay : model?.reasoningContentField === 'reasoning_content';
104
+ const replay =
105
+ protocol === 'openai-chat' && replayFlag && thinking !== 'off' && !/reasoner/i.test(String(model?.id || ''));
106
+ return { level, fields, thinking, replay };
107
+ }
@@ -1,4 +1,5 @@
1
1
  import { normalizeAnthropicEffortInput } from './anthropic-effort.mjs';
2
+ import { CUSTOM_EFFORT_LEVELS, customEffortLevels, isCustomProviderName } from './custom-reasoning.mjs';
2
3
  import { codexModelSupportsEffortUpdates } from './openai-oauth-catalog.mjs';
3
4
 
4
5
  export const EFFORT_CONFIGURATION_BETA = 'mid-conversation-output-config-2026-07-01';
@@ -42,7 +43,10 @@ export function markEffortConfigurationUnsupported(provider, model) {
42
43
 
43
44
  /** True for a provider error that names the mid-conversation effort update. */
44
45
  export function isEffortConfigurationRejection(status, text) {
45
- return status === 400 && /output_config|mid-conversation|configuration_update|reasoning_effort_update/i.test(String(text || ''));
46
+ return (
47
+ status === 400 &&
48
+ /output_config|mid-conversation|configuration_update|reasoning_effort_update/i.test(String(text || ''))
49
+ );
46
50
  }
47
51
 
48
52
  /**
@@ -57,7 +61,9 @@ export async function withEffortConfigurationFallback(provider, model, opts, sen
57
61
  const status = error?.status ?? error?.httpStatus;
58
62
  if (opts?._effortConfigurationRetry || !isEffortConfigurationRejection(status, error?.message)) throw error;
59
63
  markEffortConfigurationUnsupported(provider, model);
60
- process.stderr.write(`[${provider}] ${model} rejected the mid-conversation effort update; retrying once without it\n`);
64
+ process.stderr.write(
65
+ `[${provider}] ${model} rejected the mid-conversation effort update; retrying once without it\n`
66
+ );
61
67
  return send({ ...opts, effortConfigurationEnabled: false, _effortConfigurationRetry: true });
62
68
  }
63
69
  }
@@ -116,6 +122,42 @@ export function effortConfigurationMode(provider, model, opts = {}) {
116
122
  return null;
117
123
  }
118
124
 
125
+ // Custom providers on the Chat and Responses wires take a fresh effort with
126
+ // every request, so auto effort sends a turn's or step's effort as that
127
+ // request's own effort: no mid-conversation update, no beta header. A custom
128
+ // Anthropic-wire provider keeps auto effort off.
129
+ const CUSTOM_AUTO_EFFORT_PROTOCOLS = new Set(['openai-chat', 'openai-responses']);
130
+
131
+ /** Auto effort applies to this custom route: a Chat/Responses protocol and a
132
+ * model row declaring a non-empty effort level list. */
133
+ export function customAutoEffortCapable(provider, protocol, model) {
134
+ return (
135
+ isCustomProviderName(provider) &&
136
+ CUSTOM_AUTO_EFFORT_PROTOCOLS.has(protocol) &&
137
+ (customEffortLevels(model)?.length || 0) > 0
138
+ );
139
+ }
140
+
141
+ /** The model auto effort reads its effort options from: a custom provider's
142
+ * effective row (its declared levels), the bare id for every other route. */
143
+ export function autoEffortModel(providerName, providerImpl, model) {
144
+ if (!isCustomProviderName(providerName)) return { id: model };
145
+ return { ...(providerImpl?.getCachedModelInfo?.(model) || {}), id: model };
146
+ }
147
+
148
+ /** The effort-change mode of a session's route: 'responses' / 'anthropic'
149
+ * (mid-conversation update), 'custom' (per-request effort), or null. */
150
+ export function turnEffortConfigurationMode(session, provider) {
151
+ const opts = { ...(provider?.config || {}), modelParameters: session.modelParameters || {} };
152
+ const mode = effortConfigurationMode(session.provider, session.model, opts);
153
+ if (mode) return mode;
154
+ const model = autoEffortModel(session.provider, provider, session.model);
155
+ return customAutoEffortCapable(session.provider, provider?.customConfig?.protocol, model) ? 'custom' : null;
156
+ }
157
+
158
+ const CUSTOM_EFFORTS = new Set(CUSTOM_EFFORT_LEVELS);
159
+ const effortsFor = (mode) => (mode === 'custom' ? CUSTOM_EFFORTS : EFFORTS);
160
+
119
161
  function normalizedEffort(provider, model, effort) {
120
162
  if (ANTHROPIC_PROVIDERS.has(provider)) return normalizeAnthropicEffortInput(effort, model) || null;
121
163
  const value = String(effort || 'medium')
@@ -130,19 +172,19 @@ function validSnapshot(value, provider, model, mode) {
130
172
  value.provider === provider &&
131
173
  value.model === modelKey(model) &&
132
174
  value.mode === mode &&
133
- EFFORTS.has(value.initialEffort) &&
134
- EFFORTS.has(value.effort)
175
+ effortsFor(mode).has(value.initialEffort) &&
176
+ effortsFor(mode).has(value.effort)
135
177
  );
136
178
  }
137
179
 
138
180
  // `turnEffort` overrides the session's saved effort for this turn only (auto
139
- // effort); the saved default itself is never changed here.
181
+ // effort); the saved default itself is never changed here. A custom route's
182
+ // snapshot only records the turn's effort (per-request effort, never projected
183
+ // into the history).
140
184
  export function prepareTurnEffortConfiguration(session, provider, turnEffort) {
141
- const config = provider?.config || {};
142
- const opts = { ...config, modelParameters: session.modelParameters || {} };
143
- const mode = effortConfigurationMode(session.provider, session.model, opts);
185
+ const mode = turnEffortConfigurationMode(session, provider);
144
186
  const effort = normalizedEffort(session.provider, session.model, turnEffort ?? session.effort);
145
- if (!mode || !EFFORTS.has(effort)) return null;
187
+ if (!mode || !effortsFor(mode).has(effort)) return null;
146
188
  const first = (session.messages || []).find((message) =>
147
189
  validSnapshot(message?.meta?.[META_KEY], session.provider, session.model, mode)
148
190
  )?.meta?.[META_KEY];
@@ -76,7 +76,7 @@ export async function exchangeAuthorizationCode({ discovery, pkce, code }) {
76
76
  return tokens;
77
77
  }
78
78
 
79
- export async function beginOAuthLogin() {
79
+ export async function beginOAuthLogin({ openBrowser = true } = {}) {
80
80
  const discovery = await fetchDiscovery();
81
81
  const pkce = generatePKCE();
82
82
  const state = randomBytes(16).toString('hex');
@@ -138,6 +138,7 @@ export async function beginOAuthLogin() {
138
138
  process.stderr.write(
139
139
  `\n[grok-oauth] Open this URL to log in (consent shows as "Grok Build"):\n${url.toString()}\n\n`
140
140
  );
141
+ if (!openBrowser) return;
141
142
  try {
142
143
  const { openInBrowser } = await import('../../../shared/open-url.mjs');
143
144
  openInBrowser(url.toString());
@@ -528,16 +528,19 @@ function balanceFromCredits(credits, source) {
528
528
  if (balance === null) return null;
529
529
  return {
530
530
  source,
531
- remainingUsd: round(balance, 4),
532
- currency: cleanString(credits.currency) || 'USD',
531
+ unit: 'credits',
532
+ remainingCredits: round(balance, 4),
533
+ unlimited: credits.unlimited === true,
534
+ hasCredits: credits.has_credits === true,
533
535
  };
534
536
  }
535
537
 
536
538
  function balanceFromExtraUsage(extra) {
537
539
  if (!extra || typeof extra !== 'object') return null;
538
- const limit = num(extra.monthly_limit, null);
539
- const used = num(extra.used_credits, 0);
540
- if (limit === null) return null;
540
+ const limitCents = num(extra.monthly_limit, null);
541
+ if (limitCents === null) return null;
542
+ const limit = limitCents / 100;
543
+ const used = num(extra.used_credits, 0) / 100;
541
544
  return {
542
545
  source: 'anthropic-oauth-extra',
543
546
  period: 'monthly',
@@ -606,7 +609,7 @@ function balanceFromAnthropicSpend(spend) {
606
609
  return null;
607
610
  }
608
611
 
609
- function normalizeOpenAIWhamUsage(data) {
612
+ export function normalizeOpenAIWhamUsage(data) {
610
613
  const rate = data?.rate_limit && typeof data.rate_limit === 'object' ? data.rate_limit : null;
611
614
  if (!rate) return null;
612
615
  const windows = [
@@ -687,13 +690,15 @@ function anthropicLimitWindows(limits, source) {
687
690
  // The extra-usage (pay-as-you-go) window when it is enabled, else null.
688
691
  function anthropicExtraUsageWindow(extraUsage) {
689
692
  if (extraUsage?.is_enabled !== true) return null;
693
+ const limitUsd = num(extraUsage.monthly_limit, 0) / 100;
694
+ const usedUsd = num(extraUsage.used_credits, 0) / 100;
690
695
  return windowFromPercent(
691
696
  'EXTRA',
692
697
  {
693
698
  utilization: extraUsage.utilization,
694
- limit_dollars: extraUsage.monthly_limit,
695
- used_dollars: extraUsage.used_credits,
696
- remaining_dollars: Math.max(0, num(extraUsage.monthly_limit, 0) - num(extraUsage.used_credits, 0)),
699
+ limit_dollars: limitUsd,
700
+ used_dollars: usedUsd,
701
+ remaining_dollars: Math.max(0, limitUsd - usedUsd),
697
702
  },
698
703
  'anthropic-oauth-extra'
699
704
  );
@@ -22,6 +22,7 @@ import { applyCompatToolChoice, compatStreamRetryReporter } from './compat-reque
22
22
  import { ensureChatToolPairs } from './lib/wire-pairing.mjs';
23
23
  import { xaiCacheRouting } from './openai-compat-xai.mjs';
24
24
  import { preparePdfTextForProvider } from './media-normalization.mjs';
25
+ import { isCustomProviderName, resolveCustomReasoning } from './custom-reasoning.mjs';
25
26
 
26
27
  export async function sendCompatChat(provider, messages, useModel, tools, opts) {
27
28
  const signal = opts.signal || null;
@@ -29,13 +30,31 @@ export async function sendCompatChat(provider, messages, useModel, tools, opts)
29
30
  const reason = signal.reason;
30
31
  throw reason instanceof Error ? reason : new Error('OpenAI-compat request aborted by session close');
31
32
  }
32
- const modelInfo =
33
- provider.name === 'opencode-go'
34
- ? provider.getCachedModelInfo(useModel) || getModelMetadataSync(useModel, provider.name)
35
- : null;
36
- const replaysReasoningContent =
37
- modelInfo?.reasoningContentField === 'reasoning_content' ||
38
- (provider.name === 'deepseek' && deepseekReplaysReasoningContent(useModel));
33
+ const isCustom = isCustomProviderName(provider.name);
34
+ // Opt-in per-provider wire profile (config.chatProfile): only a wrapper that
35
+ // sets it gets the extra hooks below; every other provider is untouched.
36
+ const profile = provider.config?.chatProfile || null;
37
+ // Custom providers read only their own model rows: a same-id model in the
38
+ // global catalog must not shape a user-defined endpoint's request.
39
+ let modelInfo = null;
40
+ if (isCustom) modelInfo = provider.getCachedModelInfo(useModel) || null;
41
+ else if (provider.name === 'opencode-go')
42
+ modelInfo = provider.getCachedModelInfo(useModel) || getModelMetadataSync(useModel, provider.name);
43
+ else if (profile?.resolveModelInfo) modelInfo = profile.resolveModelInfo(provider, useModel) || null;
44
+ // Resolved before lowering: replay eligibility depends on the snapped level
45
+ // (thinking disabled → nothing to replay).
46
+ const customReasoning = isCustom
47
+ ? resolveCustomReasoning({
48
+ protocol: 'openai-chat',
49
+ model: modelInfo,
50
+ effort: opts.effort ?? provider.config?.reasoningEffort,
51
+ })
52
+ : null;
53
+ const replaysReasoningContent = isCustom
54
+ ? customReasoning.replay
55
+ : modelInfo?.reasoningContentField === 'reasoning_content' ||
56
+ profile?.replaysReasoningContent === true ||
57
+ (provider.name === 'deepseek' && deepseekReplaysReasoningContent(useModel));
39
58
  messages = await preparePdfTextForProvider(messages, provider);
40
59
  const params = {
41
60
  model: useModel,
@@ -43,7 +62,11 @@ export async function sendCompatChat(provider, messages, useModel, tools, opts)
43
62
  // (cancel/abort) is hard-rejected unpaired, so synthesize the
44
63
  // missing tool messages here.
45
64
  messages: ensureChatToolPairs(
46
- toOpenAIMessages(messages, provider.name, { replaysReasoningContent, nativePdf: provider.nativePdf })
65
+ toOpenAIMessages(messages, provider.name, {
66
+ replaysReasoningContent,
67
+ ...(isCustom ? { customReplay: customReasoning.replay } : {}),
68
+ nativePdf: provider.nativePdf,
69
+ })
47
70
  ),
48
71
  };
49
72
  const maxOutputTokens = resolveCompatMaxOutputTokens(opts);
@@ -51,6 +74,8 @@ export async function sendCompatChat(provider, messages, useModel, tools, opts)
51
74
  if (tools?.length) params.tools = toOpenAITools(tools);
52
75
  applyCompatToolChoice(params, opts);
53
76
  applyCompatProviderChatOptions(params, provider.name, opts, provider.config, modelInfo);
77
+ profile?.applyParams?.(params, { opts, config: provider.config, modelInfo });
78
+ const firstByteTimeoutMs = profile?.firstByteTimeoutMs || PROVIDER_FIRST_BYTE_TIMEOUT_MS;
54
79
  const totalSignal = createPassthroughSignal(signal);
55
80
  const cacheRouting = provider.name === 'xai' ? xaiCacheRouting(opts, params, tools || [], useModel) : null;
56
81
  const cacheRoutingKey = cacheRouting?.key || null;
@@ -60,7 +85,7 @@ export async function sendCompatChat(provider, messages, useModel, tools, opts)
60
85
  try {
61
86
  try {
62
87
  assembled = await withRetry(
63
- async ({ signal: attemptSignal }) => {
88
+ refineAttemptErrors(profile, async ({ signal: attemptSignal }) => {
64
89
  try {
65
90
  opts.onStageChange?.('requesting');
66
91
  } catch {
@@ -79,7 +104,7 @@ export async function sendCompatChat(provider, messages, useModel, tools, opts)
79
104
  // owned by the outer withRetry — nesting retry loops
80
105
  // here multiplied tail latency (5x5).
81
106
  maxAttempts: 1,
82
- perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
107
+ perAttemptTimeoutMs: firstByteTimeoutMs,
83
108
  perAttemptLabel: `${provider.name} first byte`,
84
109
  }
85
110
  );
@@ -101,6 +126,8 @@ export async function sendCompatChat(provider, messages, useModel, tools, opts)
101
126
  // recovered leaked calls only synthesize when they name
102
127
  // a tool actually offered to this request.
103
128
  knownToolNames: knownToolNamesFromOpenAITools(params.tools),
129
+ ...(profile?.firstByteTimeoutMs ? { firstByteTimeoutMs: profile.firstByteTimeoutMs } : {}),
130
+ ...(profile?.semanticIdleTimeoutMs ? { semanticIdleTimeoutMs: profile.semanticIdleTimeoutMs } : {}),
104
131
  });
105
132
  } catch (error) {
106
133
  // Grok's reference sampler treats content-idle as a
@@ -112,7 +139,7 @@ export async function sendCompatChat(provider, messages, useModel, tools, opts)
112
139
  }
113
140
  throw error;
114
141
  }
115
- },
142
+ }),
116
143
  {
117
144
  signal: totalSignal.signal,
118
145
  onRetry: compatStreamRetryReporter(provider.name, opts),
@@ -146,6 +173,19 @@ export async function sendCompatChat(provider, messages, useModel, tools, opts)
146
173
  });
147
174
  }
148
175
 
176
+ // Opt-in: a provider profile may rewrite an attempt's error (gateway detail,
177
+ // permanent-failure stamps) before the retry classifier sees it.
178
+ function refineAttemptErrors(profile, attempt) {
179
+ if (typeof profile?.refineError !== 'function') return attempt;
180
+ return async (ctx) => {
181
+ try {
182
+ return await attempt(ctx);
183
+ } catch (error) {
184
+ throw profile.refineError(error) || error;
185
+ }
186
+ };
187
+ }
188
+
149
189
  export async function recoverCompatNonStreaming(provider, { streamErr, params, opts, signal, useModel }) {
150
190
  if (!canFallbackNonStreaming(streamErr, { signal })) return null;
151
191
  noteErrorUsage(streamErr);