create-walle 0.9.21 → 0.9.22

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 (52) hide show
  1. package/README.md +5 -5
  2. package/package.json +2 -2
  3. package/template/claude-task-manager/api-prompts.js +13 -0
  4. package/template/claude-task-manager/api-reviews.js +5 -2
  5. package/template/claude-task-manager/db.js +348 -15
  6. package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
  7. package/template/claude-task-manager/docs/image-paste-ux.md +3 -0
  8. package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
  9. package/template/claude-task-manager/git-utils.js +146 -17
  10. package/template/claude-task-manager/lib/auth-rate-limit.js +23 -3
  11. package/template/claude-task-manager/lib/auth-rules.js +3 -0
  12. package/template/claude-task-manager/lib/document-review.js +33 -2
  13. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +83 -0
  14. package/template/claude-task-manager/lib/mobile-auth-api.js +14 -0
  15. package/template/claude-task-manager/lib/restart-guard.js +68 -0
  16. package/template/claude-task-manager/lib/session-standup.js +36 -13
  17. package/template/claude-task-manager/lib/session-stream.js +11 -4
  18. package/template/claude-task-manager/lib/transport-security.js +50 -0
  19. package/template/claude-task-manager/lib/walle-transcript.js +16 -0
  20. package/template/claude-task-manager/lib/worktree-active-sync.js +6 -3
  21. package/template/claude-task-manager/public/css/reviews.css +10 -0
  22. package/template/claude-task-manager/public/css/setup.css +13 -0
  23. package/template/claude-task-manager/public/css/walle.css +145 -0
  24. package/template/claude-task-manager/public/index.html +539 -44
  25. package/template/claude-task-manager/public/ipad.html +363 -0
  26. package/template/claude-task-manager/public/js/document-review-links.js +196 -0
  27. package/template/claude-task-manager/public/js/message-renderer.js +14 -3
  28. package/template/claude-task-manager/public/js/reviews.js +30 -6
  29. package/template/claude-task-manager/public/js/setup.js +42 -2
  30. package/template/claude-task-manager/public/js/stream-view.js +20 -1
  31. package/template/claude-task-manager/public/js/walle.js +314 -18
  32. package/template/claude-task-manager/public/m/app.css +789 -11
  33. package/template/claude-task-manager/public/m/app.js +1070 -67
  34. package/template/claude-task-manager/public/m/claim.html +9 -2
  35. package/template/claude-task-manager/public/m/index.html +17 -10
  36. package/template/claude-task-manager/public/m/sw.js +1 -1
  37. package/template/claude-task-manager/server.js +365 -95
  38. package/template/claude-task-manager/session-integrity.js +4 -0
  39. package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +86 -35
  40. package/template/package.json +1 -1
  41. package/template/wall-e/api-walle.js +19 -1
  42. package/template/wall-e/brain.js +152 -6
  43. package/template/wall-e/chat.js +85 -0
  44. package/template/wall-e/coding-orchestrator.js +106 -12
  45. package/template/wall-e/http/model-admin.js +131 -0
  46. package/template/wall-e/lib/service-health.js +194 -0
  47. package/template/wall-e/llm/anthropic.js +7 -0
  48. package/template/wall-e/llm/client.js +46 -12
  49. package/template/wall-e/llm/openai.js +17 -2
  50. package/template/wall-e/llm/portkey-sync.js +201 -0
  51. package/template/wall-e/server.js +13 -0
  52. package/template/website/index.html +10 -10
@@ -486,6 +486,78 @@ function parsePlan(output) {
486
486
  throw new Error('Failed to parse plan: no valid JSON with non-empty subtasks array found');
487
487
  }
488
488
 
489
+ function safeBranchSlug(text, fallback = 'task') {
490
+ const slug = String(text || '')
491
+ .toLowerCase()
492
+ .replace(/[^a-z0-9]+/g, '-')
493
+ .replace(/^-+|-+$/g, '')
494
+ .slice(0, 48);
495
+ return slug || fallback;
496
+ }
497
+
498
+ function plannerOutputRequestsClarification(output = '') {
499
+ const text = contentToText(output).toLowerCase();
500
+ if (!text.trim()) return false;
501
+ return /\b(?:please|can you|could you)\s+(?:provide|clarify|tell me|share)\b/.test(text)
502
+ || /\b(?:need|needs|require|required)\s+(?:more|additional)\s+(?:information|context|details)\b/.test(text)
503
+ || /\b(?:which|what)\s+(?:file|directory|project|repo|repository|path)\b[\s\S]{0,120}\?/.test(text);
504
+ }
505
+
506
+ function plannerOutputRefusesTask(output = '') {
507
+ const text = contentToText(output).toLowerCase();
508
+ if (!text.trim()) return false;
509
+ return /\b(?:i\s+)?(?:cannot|can't|unable to|not able to)\b[\s\S]{0,160}\b(?:help|comply|perform|complete|do this task)\b/.test(text)
510
+ || /\b(?:unsafe|not allowed|forbidden|against policy)\b/.test(text);
511
+ }
512
+
513
+ function shouldRecoverPlannerParseFailure({ request, output, cwd } = {}) {
514
+ const requestText = contentToText(request);
515
+ if (!isActionRequiredPrompt(requestText, { mode: 'build' })) return false;
516
+ if (!cwd) return false;
517
+ const outputText = contentToText(output);
518
+ if (plannerOutputRequestsClarification(outputText)) return false;
519
+ if (plannerOutputRefusesTask(outputText)) return false;
520
+ return true;
521
+ }
522
+
523
+ function buildPlannerRecoveryPlan(request, context = {}, parseErr, plannerOutput = '') {
524
+ const filesHint = Object.keys(context.relevantFiles || {}).slice(0, 12);
525
+ const plannerNotes = [
526
+ context.plannerNotes ? `Planner exploration notes:\n${String(context.plannerNotes).slice(0, 2400)}` : '',
527
+ plannerOutput ? `Unstructured planner output excerpt:\n${contentToText(plannerOutput).slice(0, 1600)}` : '',
528
+ ].filter(Boolean).join('\n\n');
529
+ const promptLines = [
530
+ 'The planning model failed to return the strict JSON plan, so this is a recovery build pass.',
531
+ 'Do not stop at analysis, an audit, or another implementation plan.',
532
+ 'Inspect the current workspace, make the concrete code/file changes requested by the user, then run the most relevant verification available.',
533
+ 'If verification is blocked, provide tool-backed evidence of the blocker instead of claiming success.',
534
+ '',
535
+ `User request:\n${contentToText(request).trim()}`,
536
+ ];
537
+ if (plannerNotes) {
538
+ promptLines.push('', plannerNotes);
539
+ }
540
+ if (parseErr?.message) {
541
+ promptLines.push('', `Planner failure: ${parseErr.message}`);
542
+ }
543
+ return {
544
+ branch_name: `walle/direct-${safeBranchSlug(request)}`,
545
+ estimated_scope: 'recovered-single-pass',
546
+ planning_recovery: {
547
+ strategy: 'single_build_subtask',
548
+ reason: parseErr?.message || 'planner did not return valid plan JSON',
549
+ },
550
+ subtasks: [{
551
+ id: '1',
552
+ title: 'Implement request directly',
553
+ prompt: promptLines.join('\n'),
554
+ depends_on: [],
555
+ verify: { test: true, review: true },
556
+ files_hint: filesHint,
557
+ }],
558
+ };
559
+ }
560
+
489
561
  // buildSubtaskPrompt moved to coding-prompts.js (imported above).
490
562
 
491
563
  function contentToText(content) {
@@ -605,8 +677,16 @@ function isActionRequiredPrompt(prompt, { mode } = {}) {
605
677
  function isPrematureActionResponse(content) {
606
678
  const text = contentToText(content);
607
679
  if (!text.trim()) return false;
680
+ if (/\btool budget exhausted\b/i.test(text)) return true;
681
+ if (/\bwhat was not completed\b/i.test(text)) return true;
682
+ if (/\bnone of the proposed implementations were written\b/i.test(text)) return true;
683
+ if (/\bno changes were made\b/i.test(text) && /\b(?:not completed|failed|exhausted|recovery path)\b/i.test(text)) return true;
608
684
  if (PROSPECTIVE_WORK_RE.test(text)) return true;
609
685
  if (/\bwhat['’]?s wrong\b[\s\S]{0,400}\bfix:/i.test(text)) return true;
686
+ if (/\b(?:should i|shall i|do you want me to)\s+(?:proceed|continue|apply|implement|make|start|do)\b/i.test(text)) return true;
687
+ if (/\byour call\b[\s\S]{0,220}\b(?:proceed|continue|phase|prioriti[sz]e|pick|choose|apply|implement)\b/i.test(text)) return true;
688
+ if (/\b(?:implementation|fix|improvement)\s+plan\b/i.test(text)
689
+ && /\b(?:next steps?|recommendations?|roadmap|proceed|continue|apply|implement)\b/i.test(text)) return true;
610
690
  return false;
611
691
  }
612
692
 
@@ -2747,20 +2827,34 @@ async function plan(request, cwd, options = {}) {
2747
2827
  if (!result.success) {
2748
2828
  parseErr.message = `Planning failed before producing valid JSON (${result.stderr || 'provider error'}): ${parseErr.message}`;
2749
2829
  }
2750
- if (process.env.WALLE_PLAN_DEBUG) {
2751
- const dumpPath = path.join(
2752
- process.env.WALL_E_DATA_DIR || '/tmp',
2753
- `planner-debug-${Date.now()}.txt`,
2754
- );
2755
- try {
2756
- fs.writeFileSync(
2757
- dumpPath,
2758
- `=== prompt ===\n${prompt}\n\n=== output ===\n${result.output || ''}\n\n=== outputRaw ===\n${result.outputRaw || ''}\n`,
2830
+ if (shouldRecoverPlannerParseFailure({ request, output: result.output, cwd })) {
2831
+ if (onProgress) {
2832
+ onProgress({
2833
+ type: 'planning_recovery',
2834
+ phase: 'planning',
2835
+ step: -1,
2836
+ message: 'Planner returned unstructured output; recovering with a direct implementation subtask.',
2837
+ detail: { reason: parseErr.message },
2838
+ });
2839
+ }
2840
+ planObj = buildPlannerRecoveryPlan(request, context, parseErr, result.output);
2841
+ config._planningRecovery = planObj.planning_recovery;
2842
+ } else {
2843
+ if (process.env.WALLE_PLAN_DEBUG) {
2844
+ const dumpPath = path.join(
2845
+ process.env.WALL_E_DATA_DIR || '/tmp',
2846
+ `planner-debug-${Date.now()}.txt`,
2759
2847
  );
2760
- parseErr.message += ` (planner debug dumped to ${dumpPath})`;
2761
- } catch {}
2848
+ try {
2849
+ fs.writeFileSync(
2850
+ dumpPath,
2851
+ `=== prompt ===\n${prompt}\n\n=== output ===\n${result.output || ''}\n\n=== outputRaw ===\n${result.outputRaw || ''}\n`,
2852
+ );
2853
+ parseErr.message += ` (planner debug dumped to ${dumpPath})`;
2854
+ } catch {}
2855
+ }
2856
+ throw parseErr;
2762
2857
  }
2763
- throw parseErr;
2764
2858
  }
2765
2859
 
2766
2860
  // Enforce max_subtasks
@@ -5,6 +5,10 @@ const path = require('path');
5
5
  const http = require('http');
6
6
  const { execFile, execFileSync } = require('child_process');
7
7
  const { jsonResponse: _jsonResponseDataFirst } = require('./api-utils');
8
+ const {
9
+ isPortkeyProviderRow,
10
+ syncPortkeyGatewayModels,
11
+ } = require('../llm/portkey-sync');
8
12
 
9
13
  let brain = null;
10
14
  try { brain = require('../brain'); } catch {}
@@ -90,13 +94,61 @@ function getShellEnvValue(name) {
90
94
 
91
95
  function getProviderWithMeta(brainApi, provider) {
92
96
  const full = brainApi.getModelProviderWithKey(provider.id);
97
+ const routePolicy = typeof brainApi.getProviderRoutePolicy === 'function'
98
+ ? brainApi.getProviderRoutePolicy(provider.type)
99
+ : 'auto';
100
+ const connectionKind = isPortkeyProviderRow(full || provider) ? 'portkey' : (provider.auth_method && provider.auth_method !== 'api_key' ? provider.auth_method : 'direct');
93
101
  return {
94
102
  ...provider,
95
103
  has_key: !!(full && full.api_key_encrypted),
96
104
  is_default_instance: provider.id.endsWith('-default') || provider.id.endsWith('-auto'),
105
+ connection_kind: connectionKind,
106
+ route_policy: routePolicy,
97
107
  };
98
108
  }
99
109
 
110
+ function buildGatewaySummary(brainApi) {
111
+ const providers = brainApi.listModelProviders().map((provider) => {
112
+ const full = brainApi.getModelProviderWithKey(provider.id) || provider;
113
+ return { ...provider, ...full };
114
+ });
115
+ const portkeyRoutes = providers.filter(isPortkeyProviderRow);
116
+ const providerTypes = Array.from(new Set(portkeyRoutes.map((route) => route.type))).sort();
117
+ const routes = portkeyRoutes.map((route) => {
118
+ const models = brainApi.listModelsByProvider(route.id) || [];
119
+ return {
120
+ id: route.id,
121
+ name: route.name,
122
+ type: route.type,
123
+ enabled: route.enabled,
124
+ model_count: models.length,
125
+ route_policy: typeof brainApi.getProviderRoutePolicy === 'function' ? brainApi.getProviderRoutePolicy(route.type) : 'auto',
126
+ };
127
+ });
128
+ const policies = {};
129
+ for (const type of providerTypes) {
130
+ policies[type] = typeof brainApi.getProviderRoutePolicy === 'function' ? brainApi.getProviderRoutePolicy(type) : 'auto';
131
+ }
132
+ const lastSuccess = brainApi.getKv?.('model_gateway_sync:portkey:last_success_at') || '';
133
+ const lastRun = brainApi.getKv?.('model_gateway_sync:portkey:last_run_at') || '';
134
+ const lastError = brainApi.getKv?.('model_gateway_sync:portkey:last_error') || '';
135
+ return [{
136
+ type: 'portkey',
137
+ name: 'Portkey Gateway',
138
+ enabled: portkeyRoutes.some((route) => route.enabled !== 0),
139
+ route_count: routes.length,
140
+ provider_count: providerTypes.length,
141
+ model_count: routes.reduce((sum, route) => sum + route.model_count, 0),
142
+ provider_types: providerTypes,
143
+ routes,
144
+ policies,
145
+ last_success_at: lastSuccess,
146
+ last_run_at: lastRun,
147
+ last_error: lastError,
148
+ next_sync_hint: 'hourly',
149
+ }];
150
+ }
151
+
100
152
  async function handleAvailableAgents(req, res) {
101
153
  const agents = ['claude', 'codex', 'gemini'];
102
154
  const result = {};
@@ -157,6 +209,85 @@ async function handleModelAdminApi(req, res, url) {
157
209
  }
158
210
  }
159
211
 
212
+ if (p === '/api/models/gateways' && m === 'GET') {
213
+ if (!brainApi) return jsonResponse(res, 500, { error: 'Wall-E brain not available' });
214
+ try {
215
+ return jsonResponse(res, 200, { gateways: buildGatewaySummary(brainApi) });
216
+ } catch (e) {
217
+ return jsonResponse(res, 500, { error: e.message });
218
+ }
219
+ }
220
+
221
+ if (p === '/api/models/provider-route-policy' && m === 'POST') {
222
+ if (!brainApi) return jsonResponse(res, 500, { error: 'Wall-E brain not available' });
223
+ try {
224
+ const body = await readSmallJsonBody(req);
225
+ const result = brainApi.setProviderRoutePolicy({ type: body.type, policy: body.policy });
226
+ return jsonResponse(res, 200, { ok: true, ...result });
227
+ } catch (e) {
228
+ return jsonResponse(res, 400, { error: e.message });
229
+ }
230
+ }
231
+
232
+ if (p === '/api/models/portkey/sync' && m === 'POST') {
233
+ if (!brainApi) return jsonResponse(res, 500, { error: 'Wall-E brain not available' });
234
+ try {
235
+ const body = await readSmallJsonBody(req);
236
+ const result = await syncPortkeyGatewayModels({ brainApi, providerId: body.provider_id || body.providerId || null });
237
+ return jsonResponse(res, result.errors.length ? 207 : 200, { ok: result.errors.length === 0, ...result });
238
+ } catch (e) {
239
+ return jsonResponse(res, 400, { error: e.message });
240
+ }
241
+ }
242
+
243
+ if (p === '/api/models/portkey/apply-all' && m === 'POST') {
244
+ if (!brainApi) return jsonResponse(res, 500, { error: 'Wall-E brain not available' });
245
+ try {
246
+ const providers = brainApi.listModelProviders()
247
+ .map((provider) => brainApi.getModelProviderWithKey(provider.id) || provider)
248
+ .filter(isPortkeyProviderRow);
249
+ const types = Array.from(new Set(providers.map((provider) => provider.type))).sort();
250
+ for (const type of types) brainApi.setProviderRoutePolicy({ type, policy: 'portkey' });
251
+ return jsonResponse(res, 200, { ok: true, gateway: 'portkey', provider_types: types });
252
+ } catch (e) {
253
+ return jsonResponse(res, 400, { error: e.message });
254
+ }
255
+ }
256
+
257
+ if (p === '/api/models/portkey/disable-default' && m === 'POST') {
258
+ if (!brainApi) return jsonResponse(res, 500, { error: 'Wall-E brain not available' });
259
+ try {
260
+ const providers = brainApi.listModelProviders()
261
+ .map((provider) => brainApi.getModelProviderWithKey(provider.id) || provider)
262
+ .filter(isPortkeyProviderRow);
263
+ const types = Array.from(new Set(providers.map((provider) => provider.type))).sort();
264
+ for (const type of types) brainApi.setProviderRoutePolicy({ type, policy: 'auto' });
265
+ return jsonResponse(res, 200, { ok: true, gateway: 'portkey', provider_types: types });
266
+ } catch (e) {
267
+ return jsonResponse(res, 400, { error: e.message });
268
+ }
269
+ }
270
+
271
+ if (p === '/api/models/gateways/portkey' && m === 'DELETE') {
272
+ if (!brainApi) return jsonResponse(res, 500, { error: 'Wall-E brain not available' });
273
+ try {
274
+ const providers = brainApi.listModelProviders()
275
+ .map((provider) => brainApi.getModelProviderWithKey(provider.id) || provider)
276
+ .filter(isPortkeyProviderRow);
277
+ const types = Array.from(new Set(providers.map((provider) => provider.type)));
278
+ let deletedProviders = 0;
279
+ let deletedModels = 0;
280
+ for (const provider of providers) {
281
+ deletedModels += brainApi.deleteModelRegistryByProvider(provider.id) || 0;
282
+ deletedProviders += brainApi.deleteModelProvider(provider.id) || 0;
283
+ }
284
+ for (const type of types) brainApi.setProviderRoutePolicy({ type, policy: 'auto' });
285
+ return jsonResponse(res, 200, { ok: true, gateway: 'portkey', deleted_providers: deletedProviders, deleted_models: deletedModels });
286
+ } catch (e) {
287
+ return jsonResponse(res, 400, { error: e.message });
288
+ }
289
+ }
290
+
160
291
  if (p === '/api/models/providers' && m === 'POST') {
161
292
  if (!brainApi) return jsonResponse(res, 500, { error: 'Wall-E brain not available' });
162
293
  try {
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ const TRANSIENT_PROVIDER_ISSUE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
4
+
5
+ function asTime(value) {
6
+ const t = value ? Date.parse(value) : NaN;
7
+ return Number.isFinite(t) ? t : 0;
8
+ }
9
+
10
+ function normalizedProviderIssue(alert) {
11
+ if (!alert || typeof alert !== 'object') return null;
12
+ const issue = alert.providerError || alert.provider_error || {};
13
+ const type = String(issue.type || alert.type || '').toLowerCase();
14
+ const service = String(alert.service || '').toLowerCase();
15
+ const looksProvider = service === 'ai_provider' || type.startsWith('ai_provider') || !!alert.provider || !!issue.provider;
16
+ if (!looksProvider) return null;
17
+ return {
18
+ id: alert.id || '',
19
+ type: issue.type || alert.type || 'provider_error',
20
+ severity: issue.severity || alert.severity || 'error',
21
+ title: issue.title || alert.title || 'AI provider issue',
22
+ message: issue.userMessage || issue.message || alert.message || 'Wall-E could not get a provider response.',
23
+ provider: issue.provider || alert.provider || '',
24
+ model: issue.model || alert.model || '',
25
+ status: issue.status || alert.status || '',
26
+ rawMessage: issue.rawMessage || issue.raw_message || alert.rawMessage || '',
27
+ actionUrl: issue.actionUrl || issue.action_url || alert.action_url || '/setup.html',
28
+ actionLabel: issue.actionLabel || issue.action_label || alert.action_label || 'Open Setup',
29
+ created_at: alert.created_at || issue.createdAt || issue.created_at || '',
30
+ };
31
+ }
32
+
33
+ function providerMatches(issue, activeProvider, activeModel) {
34
+ if (!issue) return false;
35
+ const provider = String(issue.provider || '').toLowerCase();
36
+ const model = String(issue.model || '').toLowerCase();
37
+ const ap = String(activeProvider || '').toLowerCase();
38
+ const am = String(activeModel || '').toLowerCase();
39
+ if (ap && provider && provider === ap) return true;
40
+ if (am && model && model === am) return true;
41
+ return !ap && !am;
42
+ }
43
+
44
+ function isStickyProviderIssue(issue) {
45
+ const text = `${issue.type || ''} ${issue.title || ''} ${issue.message || ''}`.toLowerCase();
46
+ return /auth|quota|billing|credit|insufficient|invalid|forbidden|unauthorized|permission/.test(text);
47
+ }
48
+
49
+ function isCurrentProviderIssue(issue, opts, nowMs) {
50
+ if (!providerMatches(issue, opts.activeProvider, opts.activeModel)) return false;
51
+ if (isStickyProviderIssue(issue)) return true;
52
+ const created = asTime(issue.created_at);
53
+ if (!created) return true;
54
+ return nowMs - created <= (opts.transientProviderMaxAgeMs || TRANSIENT_PROVIDER_ISSUE_MAX_AGE_MS);
55
+ }
56
+
57
+ function skillNameFromAlert(alert) {
58
+ if (!alert) return '';
59
+ if (alert.skill) return String(alert.skill);
60
+ const service = String(alert.service || '');
61
+ if (service && service !== 'ai_provider' && service !== 'system') return service;
62
+ const msg = String(alert.message || '');
63
+ const quoted = msg.match(/Skill\s+"([^"]+)"/i);
64
+ if (quoted) return quoted[1];
65
+ const named = msg.match(/Skill\s+([A-Za-z0-9_.-]+)/i);
66
+ return named ? named[1] : '';
67
+ }
68
+
69
+ function isDisabledSkillAlert(alert) {
70
+ return !!alert && String(alert.type || '').toLowerCase() === 'skill_disabled';
71
+ }
72
+
73
+ function isIntegrationAlert(alert) {
74
+ if (!alert || isDisabledSkillAlert(alert)) return false;
75
+ const type = String(alert.type || '').toLowerCase();
76
+ const action = String(alert.action || '').toLowerCase();
77
+ const text = [alert.service, alert.message, alert.integration].filter(Boolean).join(' ').toLowerCase();
78
+ if (action === 'gws_reauth' || action === 'repair_slack_owner') return true;
79
+ if (type === 'auth_expired' || type === 'owner_identity_missing') return true;
80
+ return /(slack|gws|google|gmail|calendar|drive|oauth)/.test(text)
81
+ && /(auth|reauth|reconnect|expired|token|owner)/.test(text);
82
+ }
83
+
84
+ function publicAlert(alert) {
85
+ return {
86
+ id: alert.id || '',
87
+ service: alert.service || '',
88
+ type: alert.type || '',
89
+ title: alert.title || '',
90
+ message: alert.message || '',
91
+ severity: alert.severity || '',
92
+ action: alert.action || '',
93
+ action_label: alert.action_label || '',
94
+ action_url: alert.action_url || '',
95
+ created_at: alert.created_at || '',
96
+ };
97
+ }
98
+
99
+ function summarizeProviderIssue(issue, nowMs) {
100
+ const created = asTime(issue.created_at);
101
+ return {
102
+ id: issue.id,
103
+ type: issue.type,
104
+ severity: issue.severity,
105
+ title: issue.title,
106
+ message: issue.message,
107
+ provider: issue.provider,
108
+ model: issue.model,
109
+ status: issue.status,
110
+ action_url: issue.actionUrl,
111
+ action_label: issue.actionLabel,
112
+ created_at: issue.created_at,
113
+ age_ms: created ? Math.max(0, nowMs - created) : null,
114
+ };
115
+ }
116
+
117
+ function buildServiceHealth(alerts, options = {}) {
118
+ const list = Array.isArray(alerts) ? alerts.filter(Boolean) : [];
119
+ const nowMs = options.nowMs || Date.now();
120
+ const providerIssues = list
121
+ .map(normalizedProviderIssue)
122
+ .filter(Boolean)
123
+ .sort((a, b) => asTime(b.created_at) - asTime(a.created_at));
124
+
125
+ const currentIssue = providerIssues.find((issue) => isCurrentProviderIssue(issue, options, nowMs)) || null;
126
+ const currentIssueId = currentIssue && currentIssue.id;
127
+ const providerHistory = providerIssues
128
+ .filter((issue) => issue.id !== currentIssueId)
129
+ .map((issue) => summarizeProviderIssue(issue, nowMs));
130
+
131
+ const disabledSkills = list.filter(isDisabledSkillAlert).map((alert) => ({
132
+ ...publicAlert(alert),
133
+ skill: skillNameFromAlert(alert),
134
+ }));
135
+ const integrationAlerts = list
136
+ .filter((alert) => !normalizedProviderIssue(alert) && !isDisabledSkillAlert(alert) && isIntegrationAlert(alert))
137
+ .map(publicAlert);
138
+ const systemAlerts = list
139
+ .filter((alert) => !normalizedProviderIssue(alert) && !isDisabledSkillAlert(alert) && !isIntegrationAlert(alert))
140
+ .map(publicAlert);
141
+
142
+ let level = 'ok';
143
+ if (currentIssue) level = currentIssue.severity === 'warning' ? 'warning' : 'error';
144
+ else if (integrationAlerts.length || disabledSkills.length || systemAlerts.some((a) => a.severity === 'error')) level = 'warning';
145
+ else if (providerHistory.length || systemAlerts.length) level = 'info';
146
+
147
+ const activeLabel = [options.activeProvider, options.activeModel].filter(Boolean).join(' / ');
148
+ let title = 'Wall-E services healthy';
149
+ let message = activeLabel ? `${activeLabel} has no current service blocker.` : 'No current service blocker.';
150
+ if (currentIssue) {
151
+ title = currentIssue.title || 'Current provider issue';
152
+ message = currentIssue.message;
153
+ } else if (integrationAlerts.length || disabledSkills.length) {
154
+ title = 'Background services need review';
155
+ const parts = [];
156
+ if (disabledSkills.length) parts.push(`${disabledSkills.length} disabled skill${disabledSkills.length === 1 ? '' : 's'}`);
157
+ if (integrationAlerts.length) parts.push(`${integrationAlerts.length} integration alert${integrationAlerts.length === 1 ? '' : 's'}`);
158
+ message = parts.join(' and ') + '.';
159
+ } else if (providerHistory.length || systemAlerts.length) {
160
+ title = 'Older service history';
161
+ message = 'No current blocker, but older provider or system alerts are available for review.';
162
+ }
163
+
164
+ return {
165
+ level,
166
+ title,
167
+ message,
168
+ active_provider: {
169
+ provider: options.activeProvider || '',
170
+ model: options.activeModel || '',
171
+ },
172
+ current_issue: currentIssue ? summarizeProviderIssue(currentIssue, nowMs) : null,
173
+ disabled_skills: disabledSkills,
174
+ integration_alerts: integrationAlerts,
175
+ provider_history: providerHistory,
176
+ system_alerts: systemAlerts,
177
+ counts: {
178
+ total: list.length,
179
+ current: currentIssue ? 1 : 0,
180
+ disabled_skills: disabledSkills.length,
181
+ integration: integrationAlerts.length,
182
+ provider_history: providerHistory.length,
183
+ system: systemAlerts.length,
184
+ },
185
+ updated_at: new Date(nowMs).toISOString(),
186
+ };
187
+ }
188
+
189
+ module.exports = {
190
+ buildServiceHealth,
191
+ normalizedProviderIssue,
192
+ skillNameFromAlert,
193
+ TRANSIENT_PROVIDER_ISSUE_MAX_AGE_MS,
194
+ };
@@ -3,6 +3,7 @@
3
3
  const Anthropic = require('@anthropic-ai/sdk');
4
4
  const { fetchWithRetry } = require('./retry');
5
5
  const { filterToSupportedModels, supportedModelsForProvider } = require('./supported-models');
6
+ const { isPortkeyProviderConfig } = require('./portkey');
6
7
 
7
8
  // ============================================================
8
9
  // Environment configuration
@@ -249,6 +250,11 @@ function createAnthropicProvider(config = {}) {
249
250
  */
250
251
  async listModels(options = {}) {
251
252
  const exact = Boolean(options.exact || options.liveOnly);
253
+ const includeUnknown = Boolean(
254
+ options.includeUnknown
255
+ || options.allowUnknown
256
+ || isPortkeyProviderConfig({ baseUrl, customHeaders })
257
+ );
252
258
  const configuredHeaders = customHeaders || {};
253
259
  const hasHeaderCredential = Boolean(
254
260
  configuredHeaders['x-api-key']
@@ -289,6 +295,7 @@ function createAnthropicProvider(config = {}) {
289
295
  if (/opus/i.test(id) || /-4-7/.test(id)) capabilities.reasoning = true;
290
296
  out.push({ id, name: _prettyAnthropicName(id, m.display_name), capabilities });
291
297
  }
298
+ if (includeUnknown) return out;
292
299
  const supported = filterToSupportedModels('anthropic', out);
293
300
  return supported.length > 0 ? supported : supportedModelsForProvider('anthropic');
294
301
  } catch (err) {
@@ -159,31 +159,38 @@ function getDefaultClient() {
159
159
  }
160
160
 
161
161
  if (providerType === 'anthropic') {
162
+ const config = _getProviderConfigFromDb(providerType);
163
+ const hasSpecificConfig = !!(config.apiKey || config.baseUrl || config.customHeaders);
164
+ if (hasSpecificConfig) {
165
+ if (!config.apiKey) config.apiKey = dbKey || process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN;
166
+ _defaultClient = createClient('anthropic', config);
167
+ return _defaultClient;
168
+ }
162
169
  // If DB has the key, set it in env so createAnthropicFromEnv picks it up
163
170
  if (dbKey && !process.env.ANTHROPIC_API_KEY) process.env.ANTHROPIC_API_KEY = dbKey;
164
171
  const fromEnv = providerRegistry.createProviderFromEnv('anthropic');
165
172
  _defaultClient = fromEnv || createClient('anthropic', { apiKey: dbKey });
166
173
  } else {
167
- const config = {};
174
+ const config = _getProviderConfigFromDb(providerType);
168
175
  if (providerType === 'openai') {
169
- config.apiKey = dbKey || process.env.OPENAI_API_KEY;
170
- if (process.env.OPENAI_BASE_URL) config.baseUrl = process.env.OPENAI_BASE_URL;
176
+ config.apiKey = config.apiKey || dbKey || process.env.OPENAI_API_KEY;
177
+ if (!config.baseUrl && process.env.OPENAI_BASE_URL) config.baseUrl = process.env.OPENAI_BASE_URL;
171
178
  } else if (providerType === 'google') {
172
- config.apiKey = dbKey || process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
179
+ config.apiKey = config.apiKey || dbKey || process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
173
180
  if (process.env.GOOGLE_AUTH_MODE === 'oauth') {
174
181
  config.authMode = 'oauth';
175
182
  config.refreshToken = process.env.GOOGLE_REFRESH_TOKEN;
176
183
  }
177
184
  } else if (providerType === 'ollama') {
178
- if (process.env.OLLAMA_BASE_URL) config.baseUrl = process.env.OLLAMA_BASE_URL;
185
+ if (!config.baseUrl && process.env.OLLAMA_BASE_URL) config.baseUrl = process.env.OLLAMA_BASE_URL;
179
186
  } else if (providerType === 'mlx') {
180
187
  if (process.env.MLX_MODEL) config.model = process.env.MLX_MODEL;
181
188
  } else if (providerType === 'deepseek') {
182
- config.apiKey = dbKey || process.env.DEEPSEEK_API_KEY;
183
- if (process.env.DEEPSEEK_BASE_URL) config.baseUrl = process.env.DEEPSEEK_BASE_URL;
189
+ config.apiKey = config.apiKey || dbKey || process.env.DEEPSEEK_API_KEY;
190
+ if (!config.baseUrl && process.env.DEEPSEEK_BASE_URL) config.baseUrl = process.env.DEEPSEEK_BASE_URL;
184
191
  } else if (providerType === 'moonshot') {
185
- config.apiKey = dbKey || process.env.MOONSHOT_API_KEY;
186
- if (process.env.MOONSHOT_BASE_URL) config.baseUrl = process.env.MOONSHOT_BASE_URL;
192
+ config.apiKey = config.apiKey || dbKey || process.env.MOONSHOT_API_KEY;
193
+ if (!config.baseUrl && process.env.MOONSHOT_BASE_URL) config.baseUrl = process.env.MOONSHOT_BASE_URL;
187
194
  }
188
195
  _defaultClient = createClient(providerType, config);
189
196
  }
@@ -198,13 +205,40 @@ function getDefaultClient() {
198
205
  function _getProviderKeyFromDb(type) {
199
206
  try {
200
207
  const brain = require('../brain');
201
- const row = brain.getDb().prepare(
202
- 'SELECT api_key_encrypted FROM model_providers WHERE type = ? AND enabled = 1 AND api_key_encrypted IS NOT NULL ORDER BY updated_at DESC LIMIT 1'
203
- ).get(type);
208
+ const row = typeof brain.getPreferredModelProviderForType === 'function'
209
+ ? brain.getPreferredModelProviderForType(type)
210
+ : brain.getDb().prepare(
211
+ 'SELECT api_key_encrypted FROM model_providers WHERE type = ? AND enabled = 1 AND api_key_encrypted IS NOT NULL ORDER BY updated_at DESC LIMIT 1'
212
+ ).get(type);
204
213
  return row?.api_key_encrypted || null;
205
214
  } catch { return null; }
206
215
  }
207
216
 
217
+ function _parseCustomHeaders(value) {
218
+ if (!value) return undefined;
219
+ if (typeof value === 'object' && !Array.isArray(value)) return value;
220
+ if (typeof value !== 'string') return undefined;
221
+ try { return JSON.parse(value); } catch { return undefined; }
222
+ }
223
+
224
+ function _getProviderConfigFromDb(type) {
225
+ try {
226
+ const brain = require('../brain');
227
+ const row = typeof brain.getPreferredModelProviderForType === 'function'
228
+ ? brain.getPreferredModelProviderForType(type)
229
+ : null;
230
+ if (!row) return {};
231
+ const config = {};
232
+ if (row.api_key_encrypted) config.apiKey = row.api_key_encrypted;
233
+ if (row.base_url) config.baseUrl = row.base_url;
234
+ const customHeaders = _parseCustomHeaders(row.custom_headers);
235
+ if (customHeaders && Object.keys(customHeaders).length > 0) config.customHeaders = customHeaders;
236
+ return config;
237
+ } catch {
238
+ return {};
239
+ }
240
+ }
241
+
208
242
  /**
209
243
  * Clear the cached default client (for testing or env changes).
210
244
  */
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { toOpenAI, messagesToOpenAI, responseFromOpenAI } = require('./tool-adapter');
4
4
  const { filterToSupportedModels, supportedModelsForProvider } = require('./supported-models');
5
+ const { isPortkeyProviderConfig } = require('./portkey');
5
6
 
6
7
  // CTM only exposes the OpenAI models it actually routes/evaluates. The
7
8
  // provider /v1/models endpoint includes legacy, audio, realtime, search, and
@@ -98,11 +99,17 @@ function createOpenAIProvider(config = {}) {
98
99
  },
99
100
 
100
101
  /**
101
- * List supported OpenAI models. We still ask the API for liveness, but
102
- * never surface arbitrary /v1/models ids in the setup UI or registry.
102
+ * List supported OpenAI models. Direct OpenAI remains constrained to CTM's
103
+ * supported catalog; Portkey/OpenAI-compatible gateways may surface models
104
+ * that CTM does not know yet, so callers can import raw gateway ids.
103
105
  */
104
106
  async listModels(options = {}) {
105
107
  const exact = Boolean(options.exact || options.liveOnly);
108
+ const includeUnknown = Boolean(
109
+ options.includeUnknown
110
+ || options.allowUnknown
111
+ || isPortkeyProviderConfig({ baseUrl, customHeaders })
112
+ );
106
113
  // Skip the API call entirely when the key is a known dummy placeholder
107
114
  // (e.g. devbox 'sk-ant-api03-unused'). Avoids 401s and unnecessary
108
115
  // network round-trips on first boot before the user configures keys.
@@ -117,6 +124,14 @@ function createOpenAIProvider(config = {}) {
117
124
  seen.add(id);
118
125
  raw.push({ id });
119
126
  }
127
+ if (includeUnknown) {
128
+ const supportedById = new Map(supportedModelsForProvider('openai').map((model) => [model.id, model]));
129
+ return raw.map((model) => supportedById.get(model.id) || {
130
+ id: model.id,
131
+ name: model.id,
132
+ capabilities: ['chat'],
133
+ });
134
+ }
120
135
  const models = filterToSupportedModels('openai', raw);
121
136
  return models.length > 0 ? models : supportedModelsForProvider('openai');
122
137
  } catch (err) {