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.
- package/README.md +5 -5
- package/package.json +2 -2
- package/template/claude-task-manager/api-prompts.js +13 -0
- package/template/claude-task-manager/api-reviews.js +5 -2
- package/template/claude-task-manager/db.js +348 -15
- package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
- package/template/claude-task-manager/docs/image-paste-ux.md +3 -0
- package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
- package/template/claude-task-manager/git-utils.js +146 -17
- package/template/claude-task-manager/lib/auth-rate-limit.js +23 -3
- package/template/claude-task-manager/lib/auth-rules.js +3 -0
- package/template/claude-task-manager/lib/document-review.js +33 -2
- package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +83 -0
- package/template/claude-task-manager/lib/mobile-auth-api.js +14 -0
- package/template/claude-task-manager/lib/restart-guard.js +68 -0
- package/template/claude-task-manager/lib/session-standup.js +36 -13
- package/template/claude-task-manager/lib/session-stream.js +11 -4
- package/template/claude-task-manager/lib/transport-security.js +50 -0
- package/template/claude-task-manager/lib/walle-transcript.js +16 -0
- package/template/claude-task-manager/lib/worktree-active-sync.js +6 -3
- package/template/claude-task-manager/public/css/reviews.css +10 -0
- package/template/claude-task-manager/public/css/setup.css +13 -0
- package/template/claude-task-manager/public/css/walle.css +145 -0
- package/template/claude-task-manager/public/index.html +539 -44
- package/template/claude-task-manager/public/ipad.html +363 -0
- package/template/claude-task-manager/public/js/document-review-links.js +196 -0
- package/template/claude-task-manager/public/js/message-renderer.js +14 -3
- package/template/claude-task-manager/public/js/reviews.js +30 -6
- package/template/claude-task-manager/public/js/setup.js +42 -2
- package/template/claude-task-manager/public/js/stream-view.js +20 -1
- package/template/claude-task-manager/public/js/walle.js +314 -18
- package/template/claude-task-manager/public/m/app.css +789 -11
- package/template/claude-task-manager/public/m/app.js +1070 -67
- package/template/claude-task-manager/public/m/claim.html +9 -2
- package/template/claude-task-manager/public/m/index.html +17 -10
- package/template/claude-task-manager/public/m/sw.js +1 -1
- package/template/claude-task-manager/server.js +365 -95
- package/template/claude-task-manager/session-integrity.js +4 -0
- package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +86 -35
- package/template/package.json +1 -1
- package/template/wall-e/api-walle.js +19 -1
- package/template/wall-e/brain.js +152 -6
- package/template/wall-e/chat.js +85 -0
- package/template/wall-e/coding-orchestrator.js +106 -12
- package/template/wall-e/http/model-admin.js +131 -0
- package/template/wall-e/lib/service-health.js +194 -0
- package/template/wall-e/llm/anthropic.js +7 -0
- package/template/wall-e/llm/client.js +46 -12
- package/template/wall-e/llm/openai.js +17 -2
- package/template/wall-e/llm/portkey-sync.js +201 -0
- package/template/wall-e/server.js +13 -0
- 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 (
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
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
|
-
|
|
2761
|
-
|
|
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.
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
102
|
-
*
|
|
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) {
|