waypoi 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/instructions/ui.instructions.md +42 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +71 -0
- package/.github/workflows/release.yml +48 -0
- package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
- package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
- package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
- package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
- package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
- package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +131 -0
- package/README.md +552 -0
- package/assets/agent-mode.png +0 -0
- package/assets/categorize.png +0 -0
- package/assets/dashboard.png +0 -0
- package/assets/endpoint-proxy.png +0 -0
- package/assets/icon.png +0 -0
- package/assets/mcp-generate-image.png +0 -0
- package/assets/mcp-understand-image.png +0 -0
- package/assets/peek-token-flow.png +0 -0
- package/assets/playground.png +0 -0
- package/assets/sankey.png +0 -0
- package/cli/index.ts +2805 -0
- package/cli/legacyRewrite.ts +108 -0
- package/cli/modelRef.ts +24 -0
- package/dist/cli/index.js +2536 -0
- package/dist/cli/legacyRewrite.js +92 -0
- package/dist/cli/modelRef.js +20 -0
- package/dist/src/benchmark/artifacts.js +131 -0
- package/dist/src/benchmark/capabilityClassifier.js +81 -0
- package/dist/src/benchmark/capabilityStore.js +144 -0
- package/dist/src/benchmark/config.js +238 -0
- package/dist/src/benchmark/gates.js +118 -0
- package/dist/src/benchmark/jobs.js +252 -0
- package/dist/src/benchmark/runner.js +1847 -0
- package/dist/src/benchmark/schema.js +353 -0
- package/dist/src/benchmark/suites.js +314 -0
- package/dist/src/benchmark/tinyQaDataset.js +422 -0
- package/dist/src/benchmark/types.js +25 -0
- package/dist/src/config.js +47 -0
- package/dist/src/index.js +178 -0
- package/dist/src/mcp/client.js +215 -0
- package/dist/src/mcp/discovery.js +226 -0
- package/dist/src/mcp/policy.js +65 -0
- package/dist/src/mcp/registry.js +129 -0
- package/dist/src/mcp/service.js +460 -0
- package/dist/src/middleware/auth.js +179 -0
- package/dist/src/middleware/requestCapture.js +192 -0
- package/dist/src/middleware/requestStats.js +118 -0
- package/dist/src/pools/builder.js +132 -0
- package/dist/src/pools/repository.js +69 -0
- package/dist/src/pools/scheduler.js +360 -0
- package/dist/src/pools/types.js +2 -0
- package/dist/src/protocols/adapters/dashscope.js +267 -0
- package/dist/src/protocols/adapters/inferenceV2.js +346 -0
- package/dist/src/protocols/adapters/openai.js +27 -0
- package/dist/src/protocols/registry.js +99 -0
- package/dist/src/protocols/types.js +2 -0
- package/dist/src/providers/health.js +153 -0
- package/dist/src/providers/importer.js +289 -0
- package/dist/src/providers/modelRegistry.js +313 -0
- package/dist/src/providers/repository.js +361 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/routes/admin.js +531 -0
- package/dist/src/routes/audio.js +295 -0
- package/dist/src/routes/chat.js +240 -0
- package/dist/src/routes/embeddings.js +157 -0
- package/dist/src/routes/images.js +288 -0
- package/dist/src/routes/mcp.js +256 -0
- package/dist/src/routes/mcpService.js +100 -0
- package/dist/src/routes/models.js +48 -0
- package/dist/src/routes/responses.js +711 -0
- package/dist/src/routes/sessions.js +450 -0
- package/dist/src/routes/stats.js +270 -0
- package/dist/src/routes/ui.js +97 -0
- package/dist/src/routes/videos.js +107 -0
- package/dist/src/routing/router.js +338 -0
- package/dist/src/services/imageGeneration.js +280 -0
- package/dist/src/services/imageUnderstanding.js +352 -0
- package/dist/src/services/videoGeneration.js +79 -0
- package/dist/src/storage/captureRepository.js +1591 -0
- package/dist/src/storage/files.js +157 -0
- package/dist/src/storage/imageCache.js +346 -0
- package/dist/src/storage/repositories.js +388 -0
- package/dist/src/storage/sessionRepository.js +370 -0
- package/dist/src/storage/statsRepository.js +204 -0
- package/dist/src/transport/httpClient.js +126 -0
- package/dist/src/types.js +2 -0
- package/dist/src/utils/messageMedia.js +285 -0
- package/dist/src/utils/modelCapabilities.js +108 -0
- package/dist/src/utils/modelDiscovery.js +170 -0
- package/dist/src/version.js +5 -0
- package/dist/src/workers/captureRetention.js +25 -0
- package/dist/src/workers/configWatcher.js +91 -0
- package/dist/src/workers/healthChecker.js +21 -0
- package/dist/src/workers/statsRotation.js +41 -0
- package/docs/LLM/output_schema.md +312 -0
- package/docs/benchmark.md +208 -0
- package/docs/mcp-guidelines.md +125 -0
- package/docs/mcp-service.md +178 -0
- package/docs/opencode.md +86 -0
- package/docs/providers.md +79 -0
- package/examples/benchmark.config.yaml +28 -0
- package/examples/providers/alibaba-dashscope.yaml +88 -0
- package/examples/providers/alibaba-llm.yaml +64 -0
- package/examples/providers/alibaba-registry.yaml +7 -0
- package/examples/providers/inference-v2-ray.yaml +29 -0
- package/examples/scenarios/assets/omni-call-sample.wav +0 -0
- package/examples/scenarios/custom.jsonl +5 -0
- package/examples/scenarios/custom.yaml +40 -0
- package/model-form-v2.png +0 -0
- package/package.json +66 -0
- package/provider-form-v2.png +0 -0
- package/provider-form.png +0 -0
- package/scripts/manual-test.sh +11 -0
- package/scripts/version-from-git.js +23 -0
- package/src/benchmark/artifacts.ts +149 -0
- package/src/benchmark/capabilityClassifier.ts +99 -0
- package/src/benchmark/capabilityStore.ts +174 -0
- package/src/benchmark/config.ts +337 -0
- package/src/benchmark/gates.ts +164 -0
- package/src/benchmark/jobs.ts +312 -0
- package/src/benchmark/runner.ts +2519 -0
- package/src/benchmark/schema.ts +443 -0
- package/src/benchmark/suites.ts +323 -0
- package/src/benchmark/tinyQaDataset.ts +428 -0
- package/src/benchmark/types.ts +442 -0
- package/src/config.ts +44 -0
- package/src/index.ts +195 -0
- package/src/mcp/client.ts +305 -0
- package/src/mcp/discovery.ts +266 -0
- package/src/mcp/policy.ts +105 -0
- package/src/mcp/registry.ts +164 -0
- package/src/mcp/service.ts +611 -0
- package/src/middleware/auth.ts +251 -0
- package/src/middleware/requestCapture.ts +245 -0
- package/src/middleware/requestStats.ts +163 -0
- package/src/pools/builder.ts +159 -0
- package/src/pools/repository.ts +71 -0
- package/src/pools/scheduler.ts +425 -0
- package/src/pools/types.ts +117 -0
- package/src/protocols/adapters/dashscope.ts +335 -0
- package/src/protocols/adapters/inferenceV2.ts +428 -0
- package/src/protocols/adapters/openai.ts +32 -0
- package/src/protocols/registry.ts +117 -0
- package/src/protocols/types.ts +81 -0
- package/src/providers/health.ts +207 -0
- package/src/providers/importer.ts +402 -0
- package/src/providers/modelRegistry.ts +415 -0
- package/src/providers/repository.ts +439 -0
- package/src/providers/types.ts +113 -0
- package/src/routes/admin.ts +666 -0
- package/src/routes/audio.ts +372 -0
- package/src/routes/chat.ts +301 -0
- package/src/routes/embeddings.ts +197 -0
- package/src/routes/images.ts +356 -0
- package/src/routes/mcp.ts +320 -0
- package/src/routes/mcpService.ts +114 -0
- package/src/routes/models.ts +50 -0
- package/src/routes/responses.ts +872 -0
- package/src/routes/sessions.ts +558 -0
- package/src/routes/stats.ts +312 -0
- package/src/routes/ui.ts +96 -0
- package/src/routes/videos.ts +132 -0
- package/src/routing/router.ts +501 -0
- package/src/services/imageGeneration.ts +396 -0
- package/src/services/imageUnderstanding.ts +449 -0
- package/src/services/videoGeneration.ts +127 -0
- package/src/storage/captureRepository.ts +1835 -0
- package/src/storage/files.ts +178 -0
- package/src/storage/imageCache.ts +405 -0
- package/src/storage/repositories.ts +494 -0
- package/src/storage/sessionRepository.ts +419 -0
- package/src/storage/statsRepository.ts +238 -0
- package/src/transport/httpClient.ts +145 -0
- package/src/types.ts +322 -0
- package/src/utils/messageMedia.ts +293 -0
- package/src/utils/modelCapabilities.ts +161 -0
- package/src/utils/modelDiscovery.ts +203 -0
- package/src/workers/captureRetention.ts +25 -0
- package/src/workers/configWatcher.ts +115 -0
- package/src/workers/healthChecker.ts +22 -0
- package/src/workers/statsRotation.ts +49 -0
- package/tests/benchmarkAdminRoutes.test.ts +82 -0
- package/tests/benchmarkBasics.test.ts +116 -0
- package/tests/captureAdminRoutes.test.ts +420 -0
- package/tests/captureRepository.test.ts +797 -0
- package/tests/cliLegacyRewrite.test.ts +45 -0
- package/tests/imageGeneration.service.test.ts +107 -0
- package/tests/imageUnderstanding.service.test.ts +123 -0
- package/tests/mcpPolicy.test.ts +105 -0
- package/tests/mcpService.test.ts +1245 -0
- package/tests/modelRef.test.ts +23 -0
- package/tests/modelsRoutes.test.ts +154 -0
- package/tests/sessionMediaCache.test.ts +167 -0
- package/tests/statsRoutes.test.ts +323 -0
- package/tsconfig.json +15 -0
- package/ui/index.html +16 -0
- package/ui/package-lock.json +8521 -0
- package/ui/package.json +52 -0
- package/ui/postcss.config.js +6 -0
- package/ui/public/assets/apple-touch-icon.png +0 -0
- package/ui/public/assets/favicon-16.png +0 -0
- package/ui/public/assets/favicon-32.png +0 -0
- package/ui/public/assets/icon-192.png +0 -0
- package/ui/public/assets/icon-512.png +0 -0
- package/ui/src/App.tsx +27 -0
- package/ui/src/api/client.ts +1503 -0
- package/ui/src/components/EndpointUsageGuide.tsx +361 -0
- package/ui/src/components/Layout.tsx +124 -0
- package/ui/src/components/MessageContent.tsx +365 -0
- package/ui/src/components/ToolCallMessage.tsx +179 -0
- package/ui/src/components/ToolPicker.tsx +442 -0
- package/ui/src/components/messageContentParser.test.ts +41 -0
- package/ui/src/components/messageContentParser.ts +73 -0
- package/ui/src/components/thinkingPreview.test.ts +27 -0
- package/ui/src/components/thinkingPreview.ts +15 -0
- package/ui/src/components/toMermaidSankey.test.ts +78 -0
- package/ui/src/components/toMermaidSankey.ts +56 -0
- package/ui/src/components/ui/button.tsx +58 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/textarea.tsx +21 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +9 -0
- package/ui/src/pages/AgentPlayground.tsx +2010 -0
- package/ui/src/pages/Benchmark.tsx +988 -0
- package/ui/src/pages/Dashboard.tsx +581 -0
- package/ui/src/pages/Peek.tsx +962 -0
- package/ui/src/pages/Settings.tsx +2013 -0
- package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
- package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
- package/ui/src/pages/agentThinkingContent.test.ts +50 -0
- package/ui/src/pages/agentThinkingContent.ts +57 -0
- package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
- package/ui/src/pages/dashboardTokenUsage.ts +36 -0
- package/ui/src/pages/imageUpload.test.ts +39 -0
- package/ui/src/pages/imageUpload.ts +71 -0
- package/ui/src/pages/peekFilters.test.ts +29 -0
- package/ui/src/pages/peekFilters.ts +13 -0
- package/ui/src/pages/peekMedia.test.ts +58 -0
- package/ui/src/pages/peekMedia.ts +148 -0
- package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
- package/ui/src/pages/sessionAutoTitle.ts +106 -0
- package/ui/src/stores/settings.ts +58 -0
- package/ui/src/styles/globals.css +223 -0
- package/ui/src/vite-env.d.ts +8 -0
- package/ui/tailwind.config.js +106 -0
- package/ui/tsconfig.json +32 -0
- package/ui/vite.config.ts +37 -0
package/cli/index.ts
ADDED
|
@@ -0,0 +1,2805 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import {
|
|
4
|
+
getModelCapabilitiesForEndpoint,
|
|
5
|
+
getUsageByEndpoint,
|
|
6
|
+
listEndpoints,
|
|
7
|
+
setEndpointDisabled,
|
|
8
|
+
updateHealthCheck
|
|
9
|
+
} from "../src/storage/repositories";
|
|
10
|
+
import { Agent, request } from "undici";
|
|
11
|
+
import { ensureStorageDir, resolveStoragePaths } from "../src/storage/files";
|
|
12
|
+
import { spawn } from "child_process";
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import { routeRequest } from "../src/routing/router";
|
|
16
|
+
import { aggregateStats } from "../src/storage/statsRepository";
|
|
17
|
+
import { listMcpServers, addMcpServer, removeMcpServer, updateMcpServer } from "../src/mcp/registry";
|
|
18
|
+
import { listBenchmarkExamples, runBenchmark } from "../src/benchmark/runner";
|
|
19
|
+
import { importProviders } from "../src/providers/importer";
|
|
20
|
+
import { listModelsForApi } from "../src/providers/modelRegistry";
|
|
21
|
+
import { getProviderModelHealthMap, probeProviderModels } from "../src/providers/health";
|
|
22
|
+
import {
|
|
23
|
+
canonicalProviderModelId,
|
|
24
|
+
deleteProviderModel,
|
|
25
|
+
getProviderById,
|
|
26
|
+
getProviderModel,
|
|
27
|
+
listProviderModels,
|
|
28
|
+
listProviders,
|
|
29
|
+
setProviderModelApiKey,
|
|
30
|
+
setProviderModelEnabled,
|
|
31
|
+
setProviderEnabled,
|
|
32
|
+
updateProvider,
|
|
33
|
+
updateProviderModel,
|
|
34
|
+
normalizeDomainSuffixes,
|
|
35
|
+
upsertProvider,
|
|
36
|
+
upsertProviderModel,
|
|
37
|
+
} from "../src/providers/repository";
|
|
38
|
+
import { rebuildDefaultPools } from "../src/pools/builder";
|
|
39
|
+
import { listPools } from "../src/pools/repository";
|
|
40
|
+
import { canonicalizeProtocol, hasProtocolAdapter, listAdapterOperations } from "../src/protocols/registry";
|
|
41
|
+
import { ProviderModelRecord, ProviderProtocol, ProviderRecord } from "../src/providers/types";
|
|
42
|
+
import { ModelCapabilities, ModelModality } from "../src/types";
|
|
43
|
+
import { rewriteLegacyArgv, LegacyRewriteResult } from "./legacyRewrite";
|
|
44
|
+
import { parseModelRef } from "./modelRef";
|
|
45
|
+
import { appConfig } from "../src/config";
|
|
46
|
+
|
|
47
|
+
if (!process.env.WAYPOI_DIR && appConfig.storageDirOverride) {
|
|
48
|
+
process.env.WAYPOI_DIR = appConfig.storageDirOverride;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const program = new Command();
|
|
52
|
+
|
|
53
|
+
type CanonicalCommandContext = {
|
|
54
|
+
json?: boolean;
|
|
55
|
+
quiet?: boolean;
|
|
56
|
+
noColor?: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const paths = resolveStoragePaths();
|
|
60
|
+
const pidFile = path.join(paths.baseDir, appConfig.pidFileName);
|
|
61
|
+
const DEFAULT_PORT = String(appConfig.port);
|
|
62
|
+
const DISPLAY_NAME = appConfig.appName.charAt(0).toUpperCase() + appConfig.appName.slice(1);
|
|
63
|
+
|
|
64
|
+
function resolveDefaultRegistryPath(): string {
|
|
65
|
+
const candidates = [
|
|
66
|
+
path.resolve(process.cwd(), "providers/free-llm-api/registry.yaml"),
|
|
67
|
+
path.resolve(__dirname, "../providers/free-llm-api/registry.yaml"),
|
|
68
|
+
path.resolve(__dirname, "../../providers/free-llm-api/registry.yaml"),
|
|
69
|
+
];
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
if (fs.existsSync(candidate)) {
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return candidates[0];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Perform an on-demand health check for all endpoints.
|
|
80
|
+
* Updates health.json with fresh status before returning.
|
|
81
|
+
*/
|
|
82
|
+
async function refreshHealthStatus(): Promise<void> {
|
|
83
|
+
const endpoints = (await listEndpoints(paths)).filter((endpoint) => !endpoint.disabled);
|
|
84
|
+
await Promise.all(
|
|
85
|
+
endpoints.map(async (endpoint) => {
|
|
86
|
+
const start = Date.now();
|
|
87
|
+
try {
|
|
88
|
+
const dispatcher = endpoint.insecureTls
|
|
89
|
+
? new Agent({ connect: { rejectUnauthorized: false } })
|
|
90
|
+
: undefined;
|
|
91
|
+
const url = new URL("/v1/models", endpoint.baseUrl).toString();
|
|
92
|
+
const headers: Record<string, string> = {};
|
|
93
|
+
if (endpoint.apiKey) {
|
|
94
|
+
headers.authorization = `Bearer ${endpoint.apiKey}`;
|
|
95
|
+
}
|
|
96
|
+
const response = await request(url, {
|
|
97
|
+
method: "GET",
|
|
98
|
+
headers,
|
|
99
|
+
headersTimeout: 3000,
|
|
100
|
+
bodyTimeout: 3000,
|
|
101
|
+
dispatcher
|
|
102
|
+
});
|
|
103
|
+
const latency = Date.now() - start;
|
|
104
|
+
response.body.resume();
|
|
105
|
+
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
106
|
+
await updateHealthCheck(paths, endpoint.id, "up", latency);
|
|
107
|
+
console.log(`✓ ${endpoint.name}: UP (${response.statusCode}, ${latency}ms)`);
|
|
108
|
+
} else {
|
|
109
|
+
await updateHealthCheck(paths, endpoint.id, "down", null);
|
|
110
|
+
console.log(`✗ ${endpoint.name}: DOWN (status ${response.statusCode})`);
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
await updateHealthCheck(paths, endpoint.id, "down", null);
|
|
114
|
+
const errorMsg = (error as Error).message || "unknown error";
|
|
115
|
+
console.log(`✗ ${endpoint.name}: DOWN (${errorMsg})`);
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function printJson(payload: unknown): void {
|
|
122
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function printWarning(message: string): void {
|
|
126
|
+
console.error(message);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function printErrorWithSuggestion(message: string, suggestions: string[] = []): void {
|
|
130
|
+
console.error(message);
|
|
131
|
+
for (const suggestion of suggestions) {
|
|
132
|
+
console.error(suggestion);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function warnLegacyRewrite(result: LegacyRewriteResult): void {
|
|
137
|
+
if (!result.legacyUsed || process.env.WAYPOI_NO_WARN === "1") {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const oldCmd = result.oldCmd ?? "";
|
|
141
|
+
const newCmd = result.newCmd ? `waypoi ${result.newCmd}` : "waypoi --help";
|
|
142
|
+
printWarning(`Deprecated command: ${oldCmd}`);
|
|
143
|
+
printWarning(`Use instead: ${newCmd}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
program
|
|
147
|
+
.name(appConfig.appName)
|
|
148
|
+
.description("Waypoi proxy and operations CLI")
|
|
149
|
+
.version("0.7.1-beta.3")
|
|
150
|
+
.option("--json", "Machine-readable JSON output where supported")
|
|
151
|
+
.option("--quiet", "Suppress non-essential output")
|
|
152
|
+
.option("--no-color", "Disable ANSI color output");
|
|
153
|
+
|
|
154
|
+
program.addHelpText(
|
|
155
|
+
"after",
|
|
156
|
+
`
|
|
157
|
+
Examples:
|
|
158
|
+
${appConfig.appName} providers
|
|
159
|
+
${appConfig.appName} models
|
|
160
|
+
${appConfig.appName} models show provider-id/model-id
|
|
161
|
+
${appConfig.appName} service
|
|
162
|
+
${appConfig.appName} logs -f
|
|
163
|
+
${appConfig.appName} bench --suite smoke
|
|
164
|
+
`
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
program
|
|
168
|
+
.command("add")
|
|
169
|
+
.description("Add a new endpoint")
|
|
170
|
+
.requiredOption("--name <name>", "Endpoint display name")
|
|
171
|
+
.requiredOption("--url <url>", "Base URL of the endpoint")
|
|
172
|
+
.requiredOption("--priority <priority>", "Routing priority (lower = preferred)")
|
|
173
|
+
.option("--type <type>", "Endpoint type: llm (chat/completions), diffusion (/images/generations), audio, embedding", "llm")
|
|
174
|
+
.option("--insecureTls", "Allow self-signed TLS certificates")
|
|
175
|
+
.option("--apiKey <apiKey>", "Bearer token for Authorization header")
|
|
176
|
+
.option("--model <mapping...>", "Model mapping as 'public' or 'public=upstream'. If endpoint has 1 model, upstream is auto-detected.")
|
|
177
|
+
.action(async () => {
|
|
178
|
+
console.error(
|
|
179
|
+
"Endpoint writes are deprecated in v0.5.0. Use `waypoi models add ...` and migration commands."
|
|
180
|
+
);
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
program
|
|
185
|
+
.command("ls")
|
|
186
|
+
.option("--no-check", "Skip health check for faster listing")
|
|
187
|
+
.option("--verbose", "Show full endpoint fields")
|
|
188
|
+
.action(async (options) => {
|
|
189
|
+
await ensureStorageDir(paths);
|
|
190
|
+
// Refresh health status unless --no-check is specified
|
|
191
|
+
if (options.check !== false) {
|
|
192
|
+
await refreshHealthStatus();
|
|
193
|
+
}
|
|
194
|
+
const endpoints = await listEndpoints(paths);
|
|
195
|
+
if (endpoints.length === 0) {
|
|
196
|
+
console.log("No endpoints found.");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const rows = options.verbose
|
|
200
|
+
? endpoints.map((endpoint) => ({
|
|
201
|
+
id: endpoint.id,
|
|
202
|
+
name: endpoint.name,
|
|
203
|
+
baseUrl: endpoint.baseUrl,
|
|
204
|
+
type: endpoint.type,
|
|
205
|
+
disabled: endpoint.disabled ? "yes" : "no",
|
|
206
|
+
status: endpoint.health.status,
|
|
207
|
+
priority: endpoint.priority,
|
|
208
|
+
}))
|
|
209
|
+
: endpoints.map((endpoint) => ({
|
|
210
|
+
name: endpoint.name,
|
|
211
|
+
host: compactEndpointUrl(endpoint.baseUrl),
|
|
212
|
+
type: endpoint.type,
|
|
213
|
+
disabled: endpoint.disabled ? "yes" : "no",
|
|
214
|
+
status: endpoint.health.status,
|
|
215
|
+
prio: endpoint.priority,
|
|
216
|
+
}));
|
|
217
|
+
console.table(rows);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
program
|
|
221
|
+
.command("test")
|
|
222
|
+
.argument("<model>")
|
|
223
|
+
.action(async (model) => {
|
|
224
|
+
await ensureStorageDir(paths);
|
|
225
|
+
const start = Date.now();
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
try {
|
|
228
|
+
const type = await resolveModelType(model);
|
|
229
|
+
const isImage = type === "diffusion";
|
|
230
|
+
const requestPath = isImage ? "/v1/images/generations" : "/v1/chat/completions";
|
|
231
|
+
const payload = isImage
|
|
232
|
+
? { model, prompt: "A small blue square on a white background." }
|
|
233
|
+
: { model, messages: [{ role: "user", content: "Say hello in one short sentence." }], max_tokens: 32 };
|
|
234
|
+
const outcome = await routeRequest(paths, model, requestPath, payload, {}, controller.signal);
|
|
235
|
+
const responseBody = await readResponsePayload(outcome.attempt.response);
|
|
236
|
+
const latency = Date.now() - start;
|
|
237
|
+
console.log(JSON.stringify({ status: outcome.attempt.response.statusCode, latencyMs: latency, response: responseBody }, null, 2));
|
|
238
|
+
} catch (error) {
|
|
239
|
+
console.error((error as Error).message);
|
|
240
|
+
process.exitCode = 1;
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
program
|
|
245
|
+
.command("rm")
|
|
246
|
+
.argument("<idOrName>")
|
|
247
|
+
.action(async () => {
|
|
248
|
+
console.error(
|
|
249
|
+
"Endpoint writes are deprecated in v0.5.0. Disable or migrate endpoints instead of deleting them."
|
|
250
|
+
);
|
|
251
|
+
process.exitCode = 1;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
program
|
|
255
|
+
.command("edit")
|
|
256
|
+
.description("Open the config file in your editor")
|
|
257
|
+
.action(async () => {
|
|
258
|
+
console.error(
|
|
259
|
+
"Endpoint config edit is blocked in v0.5.0. Use provider/model management commands instead."
|
|
260
|
+
);
|
|
261
|
+
process.exitCode = 1;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
program
|
|
265
|
+
.command("stat")
|
|
266
|
+
.description("Run a health check against each endpoint")
|
|
267
|
+
.action(async () => {
|
|
268
|
+
await ensureStorageDir(paths);
|
|
269
|
+
const endpoints = await listEndpoints(paths);
|
|
270
|
+
const activeEndpoints = endpoints.filter((endpoint) => !endpoint.disabled);
|
|
271
|
+
if (activeEndpoints.length === 0) {
|
|
272
|
+
console.log("No endpoints found.");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const results = await Promise.all(
|
|
276
|
+
activeEndpoints.map(async (endpoint) => {
|
|
277
|
+
const start = Date.now();
|
|
278
|
+
try {
|
|
279
|
+
const dispatcher = endpoint.insecureTls
|
|
280
|
+
? new Agent({ connect: { rejectUnauthorized: false } })
|
|
281
|
+
: undefined;
|
|
282
|
+
const headers: Record<string, string> = {};
|
|
283
|
+
if (endpoint.apiKey) {
|
|
284
|
+
headers.authorization = `Bearer ${endpoint.apiKey}`;
|
|
285
|
+
}
|
|
286
|
+
const response = await request(new URL("/v1/models", endpoint.baseUrl).toString(), {
|
|
287
|
+
method: "GET",
|
|
288
|
+
headers,
|
|
289
|
+
headersTimeout: 3000,
|
|
290
|
+
bodyTimeout: 3000,
|
|
291
|
+
dispatcher
|
|
292
|
+
});
|
|
293
|
+
response.body.resume();
|
|
294
|
+
const latency = Date.now() - start;
|
|
295
|
+
const status = response.statusCode >= 200 && response.statusCode < 300 ? "up" : "down";
|
|
296
|
+
await updateHealthCheck(paths, endpoint.id, status, status === "up" ? latency : null);
|
|
297
|
+
return { name: endpoint.name, status, statusCode: response.statusCode, latencyMs: latency };
|
|
298
|
+
} catch (error) {
|
|
299
|
+
await updateHealthCheck(paths, endpoint.id, "down", null);
|
|
300
|
+
return { name: endpoint.name, status: "down", error: (error as Error).message };
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
);
|
|
304
|
+
const disabledRows = endpoints
|
|
305
|
+
.filter((endpoint) => endpoint.disabled)
|
|
306
|
+
.map((endpoint) => ({
|
|
307
|
+
name: endpoint.name,
|
|
308
|
+
status: "disabled",
|
|
309
|
+
error: "skipped (disabled)",
|
|
310
|
+
}));
|
|
311
|
+
if (disabledRows.length > 0) {
|
|
312
|
+
results.push(...disabledRows);
|
|
313
|
+
}
|
|
314
|
+
console.table(results);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Alias: waypoi status -> waypoi stat
|
|
318
|
+
program
|
|
319
|
+
.command("status")
|
|
320
|
+
.description("Alias for 'stat' - Run a health check against each endpoint")
|
|
321
|
+
.action(async () => {
|
|
322
|
+
await ensureStorageDir(paths);
|
|
323
|
+
const endpoints = await listEndpoints(paths);
|
|
324
|
+
const activeEndpoints = endpoints.filter((endpoint) => !endpoint.disabled);
|
|
325
|
+
if (activeEndpoints.length === 0) {
|
|
326
|
+
console.log("No endpoints found.");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const results = await Promise.all(
|
|
330
|
+
activeEndpoints.map(async (endpoint) => {
|
|
331
|
+
const start = Date.now();
|
|
332
|
+
try {
|
|
333
|
+
const dispatcher = endpoint.insecureTls
|
|
334
|
+
? new Agent({ connect: { rejectUnauthorized: false } })
|
|
335
|
+
: undefined;
|
|
336
|
+
const headers: Record<string, string> = {};
|
|
337
|
+
if (endpoint.apiKey) {
|
|
338
|
+
headers.authorization = `Bearer ${endpoint.apiKey}`;
|
|
339
|
+
}
|
|
340
|
+
const response = await request(new URL("/v1/models", endpoint.baseUrl).toString(), {
|
|
341
|
+
method: "GET",
|
|
342
|
+
headers,
|
|
343
|
+
headersTimeout: 3000,
|
|
344
|
+
bodyTimeout: 3000,
|
|
345
|
+
dispatcher
|
|
346
|
+
});
|
|
347
|
+
response.body.resume();
|
|
348
|
+
const latency = Date.now() - start;
|
|
349
|
+
const status = response.statusCode >= 200 && response.statusCode < 300 ? "up" : "down";
|
|
350
|
+
await updateHealthCheck(paths, endpoint.id, status, status === "up" ? latency : null);
|
|
351
|
+
return { name: endpoint.name, status, statusCode: response.statusCode, latencyMs: latency };
|
|
352
|
+
} catch (error) {
|
|
353
|
+
await updateHealthCheck(paths, endpoint.id, "down", null);
|
|
354
|
+
return { name: endpoint.name, status: "down", error: (error as Error).message };
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
);
|
|
358
|
+
const disabledRows = endpoints
|
|
359
|
+
.filter((endpoint) => endpoint.disabled)
|
|
360
|
+
.map((endpoint) => ({
|
|
361
|
+
name: endpoint.name,
|
|
362
|
+
status: "disabled",
|
|
363
|
+
error: "skipped (disabled)",
|
|
364
|
+
}));
|
|
365
|
+
if (disabledRows.length > 0) {
|
|
366
|
+
results.push(...disabledRows);
|
|
367
|
+
}
|
|
368
|
+
console.table(results);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
program
|
|
372
|
+
.command("acct")
|
|
373
|
+
.description("Aggregate token usage per endpoint from logs")
|
|
374
|
+
.action(async () => {
|
|
375
|
+
await ensureStorageDir(paths);
|
|
376
|
+
const endpoints = await listEndpoints(paths);
|
|
377
|
+
const usage = await getUsageByEndpoint(paths);
|
|
378
|
+
if (usage.length === 0) {
|
|
379
|
+
console.log("No usage records found.");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const byId = new Map(usage.map((entry) => [entry.endpointId, entry]));
|
|
383
|
+
const rows = endpoints.map((endpoint) => {
|
|
384
|
+
const entry = byId.get(endpoint.id);
|
|
385
|
+
return {
|
|
386
|
+
id: endpoint.id,
|
|
387
|
+
name: endpoint.name,
|
|
388
|
+
totalTokens: entry?.totalTokens ?? 0,
|
|
389
|
+
requests: entry?.count ?? 0
|
|
390
|
+
};
|
|
391
|
+
});
|
|
392
|
+
console.table(rows);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const service = program
|
|
396
|
+
.command("service")
|
|
397
|
+
.alias("srv")
|
|
398
|
+
.description("Manage the Waypoi service process")
|
|
399
|
+
.action(async () => {
|
|
400
|
+
await ensureStorageDir(paths);
|
|
401
|
+
const pid = readPid(pidFile);
|
|
402
|
+
if (pid && isRunning(pidFile)) {
|
|
403
|
+
console.log(`${DISPLAY_NAME} is running (pid ${pid}).`);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
console.log(`${DISPLAY_NAME} is not running.`);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
service
|
|
410
|
+
.command("start")
|
|
411
|
+
.description("Start Waypoi in the background (PID file)")
|
|
412
|
+
.action(async () => {
|
|
413
|
+
await startService();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
service
|
|
417
|
+
.command("stop")
|
|
418
|
+
.description("Stop Waypoi")
|
|
419
|
+
.action(async () => {
|
|
420
|
+
await stopService();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
service
|
|
424
|
+
.command("restart")
|
|
425
|
+
.description("Restart Waypoi")
|
|
426
|
+
.action(async () => {
|
|
427
|
+
await stopService();
|
|
428
|
+
await startService();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
service
|
|
432
|
+
.command("status")
|
|
433
|
+
.description("Show service status")
|
|
434
|
+
.action(async () => {
|
|
435
|
+
await ensureStorageDir(paths);
|
|
436
|
+
const pid = readPid(pidFile);
|
|
437
|
+
if (pid && isRunning(pidFile)) {
|
|
438
|
+
console.log(`${DISPLAY_NAME} is running (pid ${pid}).`);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
console.log(`${DISPLAY_NAME} is not running.`);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
445
|
+
// Logs Command
|
|
446
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
program
|
|
449
|
+
.command("logs")
|
|
450
|
+
.description("Tail the waypoi log file")
|
|
451
|
+
.option("-f, --follow", "Follow log output (like tail -f)")
|
|
452
|
+
.option("-n, --lines <n>", "Number of lines to show", "50")
|
|
453
|
+
.action(async (options) => {
|
|
454
|
+
await ensureStorageDir(paths);
|
|
455
|
+
const logFile = path.join(paths.baseDir, "waypoi.log");
|
|
456
|
+
|
|
457
|
+
if (!fs.existsSync(logFile)) {
|
|
458
|
+
console.log("No log file found. Start the service first.");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const lines = Number(options.lines) || 50;
|
|
463
|
+
|
|
464
|
+
if (options.follow) {
|
|
465
|
+
// Tail with follow using spawn
|
|
466
|
+
const tail = spawn("tail", ["-n", String(lines), "-f", logFile], {
|
|
467
|
+
stdio: "inherit"
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
process.on("SIGINT", () => {
|
|
471
|
+
tail.kill();
|
|
472
|
+
process.exit(0);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
await new Promise((resolve) => {
|
|
476
|
+
tail.on("exit", resolve);
|
|
477
|
+
});
|
|
478
|
+
} else {
|
|
479
|
+
// Just show last N lines
|
|
480
|
+
try {
|
|
481
|
+
const content = fs.readFileSync(logFile, "utf8");
|
|
482
|
+
const allLines = content.split("\n");
|
|
483
|
+
const lastLines = allLines.slice(-lines).join("\n");
|
|
484
|
+
console.log(lastLines);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
console.error(`Failed to read log file: ${(error as Error).message}`);
|
|
487
|
+
process.exitCode = 1;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
493
|
+
// Stats Command
|
|
494
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
program
|
|
497
|
+
.command("stats")
|
|
498
|
+
.description("Show request statistics")
|
|
499
|
+
.option("--window <window>", "Time window (e.g., 24h, 7d)", "7d")
|
|
500
|
+
.option("--json", "Output as JSON")
|
|
501
|
+
.action(async (options) => {
|
|
502
|
+
await ensureStorageDir(paths);
|
|
503
|
+
|
|
504
|
+
// Parse window
|
|
505
|
+
const windowStr = options.window;
|
|
506
|
+
let windowMs: number;
|
|
507
|
+
if (windowStr.endsWith("h")) {
|
|
508
|
+
windowMs = parseInt(windowStr) * 60 * 60 * 1000;
|
|
509
|
+
} else if (windowStr.endsWith("d")) {
|
|
510
|
+
windowMs = parseInt(windowStr) * 24 * 60 * 60 * 1000;
|
|
511
|
+
} else {
|
|
512
|
+
windowMs = parseInt(windowStr) || 7 * 24 * 60 * 60 * 1000;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
const stats = await aggregateStats(paths, windowMs);
|
|
517
|
+
|
|
518
|
+
if (options.json) {
|
|
519
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Pretty print
|
|
524
|
+
console.log("\n📊 Waypoi Statistics");
|
|
525
|
+
console.log(` Window: ${stats.window}\n`);
|
|
526
|
+
|
|
527
|
+
console.log("── Request Summary ──");
|
|
528
|
+
console.log(` Total: ${stats.total}`);
|
|
529
|
+
console.log(` Success: ${stats.success}`);
|
|
530
|
+
console.log(` Errors: ${stats.errors}`);
|
|
531
|
+
console.log(` Rate: ${stats.total > 0 ? ((stats.success / stats.total) * 100).toFixed(1) : 0}% success\n`);
|
|
532
|
+
|
|
533
|
+
console.log("── Latency (ms) ──");
|
|
534
|
+
console.log(` Avg: ${stats.avgLatencyMs?.toFixed(0) ?? "N/A"}`);
|
|
535
|
+
console.log(` P50: ${stats.p50LatencyMs?.toFixed(0) ?? "N/A"}`);
|
|
536
|
+
console.log(` P95: ${stats.p95LatencyMs?.toFixed(0) ?? "N/A"}`);
|
|
537
|
+
console.log(` P99: ${stats.p99LatencyMs?.toFixed(0) ?? "N/A"}\n`);
|
|
538
|
+
|
|
539
|
+
console.log("── Token Usage ──");
|
|
540
|
+
console.log(` Total: ${stats.totalTokens.toLocaleString()}`);
|
|
541
|
+
console.log(` Per Hour: ${stats.tokensPerHour?.toFixed(0) ?? "N/A"}\n`);
|
|
542
|
+
|
|
543
|
+
if (Object.keys(stats.byModel).length > 0) {
|
|
544
|
+
console.log("── By Model ──");
|
|
545
|
+
console.table(
|
|
546
|
+
Object.entries(stats.byModel).map(([model, data]) => ({
|
|
547
|
+
model,
|
|
548
|
+
requests: data.count,
|
|
549
|
+
avgLatencyMs: data.avgLatencyMs.toFixed(0),
|
|
550
|
+
tokens: data.tokens.toLocaleString()
|
|
551
|
+
}))
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (Object.keys(stats.byEndpoint).length > 0) {
|
|
556
|
+
console.log("── By Endpoint ──");
|
|
557
|
+
console.table(
|
|
558
|
+
Object.entries(stats.byEndpoint).map(([id, data]) => ({
|
|
559
|
+
id: id.slice(0, 8),
|
|
560
|
+
requests: data.count,
|
|
561
|
+
avgLatencyMs: data.avgLatencyMs.toFixed(0),
|
|
562
|
+
tokens: data.tokens.toLocaleString(),
|
|
563
|
+
errors: data.errors
|
|
564
|
+
}))
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
} catch (error) {
|
|
568
|
+
console.error(`Failed to load stats: ${(error as Error).message}`);
|
|
569
|
+
process.exitCode = 1;
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
574
|
+
// MCP Commands
|
|
575
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
async function listMcpServersAction(options: { json?: boolean } = {}): Promise<void> {
|
|
578
|
+
await ensureStorageDir(paths);
|
|
579
|
+
try {
|
|
580
|
+
const servers = await listMcpServers(paths);
|
|
581
|
+
|
|
582
|
+
if (servers.length === 0) {
|
|
583
|
+
console.log("No MCP servers configured.");
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (options.json) {
|
|
588
|
+
printJson(servers);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
console.table(
|
|
593
|
+
servers.map((s) => ({
|
|
594
|
+
id: s.id.slice(0, 8),
|
|
595
|
+
name: s.name,
|
|
596
|
+
url: s.url,
|
|
597
|
+
status: s.status,
|
|
598
|
+
enabled: s.enabled ? "✓" : "✗",
|
|
599
|
+
tools: s.toolCount ?? 0
|
|
600
|
+
}))
|
|
601
|
+
);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
console.error(`Failed to list servers: ${(error as Error).message}`);
|
|
604
|
+
process.exitCode = 1;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const mcp = program
|
|
609
|
+
.command("mcp")
|
|
610
|
+
.description("Manage MCP servers for agentic workflows")
|
|
611
|
+
.action(async () => {
|
|
612
|
+
await listMcpServersAction();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
mcp
|
|
616
|
+
.command("add")
|
|
617
|
+
.description("Add a new MCP server")
|
|
618
|
+
.requiredOption("--name <name>", "Server name")
|
|
619
|
+
.requiredOption("--url <url>", "Server URL (streamable HTTP)")
|
|
620
|
+
.option("--disabled", "Add as disabled")
|
|
621
|
+
.action(async (options) => {
|
|
622
|
+
await ensureStorageDir(paths);
|
|
623
|
+
try {
|
|
624
|
+
const server = await addMcpServer(paths, {
|
|
625
|
+
name: options.name,
|
|
626
|
+
url: options.url,
|
|
627
|
+
enabled: !options.disabled
|
|
628
|
+
});
|
|
629
|
+
console.log(`Added MCP server: ${server.name}`);
|
|
630
|
+
console.log(JSON.stringify(server, null, 2));
|
|
631
|
+
} catch (error) {
|
|
632
|
+
console.error(`Failed to add server: ${(error as Error).message}`);
|
|
633
|
+
process.exitCode = 1;
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
mcp
|
|
638
|
+
.command("list")
|
|
639
|
+
.alias("ls")
|
|
640
|
+
.description("List all MCP servers")
|
|
641
|
+
.option("--json", "Output as JSON")
|
|
642
|
+
.action(async (options) => {
|
|
643
|
+
await listMcpServersAction(options);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
mcp
|
|
647
|
+
.command("rm")
|
|
648
|
+
.alias("remove")
|
|
649
|
+
.description("Remove an MCP server")
|
|
650
|
+
.argument("<idOrName>", "Server ID (prefix) or name")
|
|
651
|
+
.action(async (idOrName) => {
|
|
652
|
+
await ensureStorageDir(paths);
|
|
653
|
+
try {
|
|
654
|
+
const servers = await listMcpServers(paths);
|
|
655
|
+
const server = servers.find(
|
|
656
|
+
(s) => s.id.startsWith(idOrName) || s.name.toLowerCase() === idOrName.toLowerCase()
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
if (!server) {
|
|
660
|
+
console.error("Server not found");
|
|
661
|
+
process.exitCode = 1;
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
await removeMcpServer(paths, server.id);
|
|
666
|
+
console.log(`Removed MCP server: ${server.name}`);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
console.error(`Failed to remove server: ${(error as Error).message}`);
|
|
669
|
+
process.exitCode = 1;
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
mcp
|
|
674
|
+
.command("enable")
|
|
675
|
+
.description("Enable an MCP server")
|
|
676
|
+
.argument("<idOrName>", "Server ID (prefix) or name")
|
|
677
|
+
.action(async (idOrName) => {
|
|
678
|
+
await ensureStorageDir(paths);
|
|
679
|
+
try {
|
|
680
|
+
const servers = await listMcpServers(paths);
|
|
681
|
+
const server = servers.find(
|
|
682
|
+
(s) => s.id.startsWith(idOrName) || s.name.toLowerCase() === idOrName.toLowerCase()
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
if (!server) {
|
|
686
|
+
console.error("Server not found");
|
|
687
|
+
process.exitCode = 1;
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
await updateMcpServer(paths, server.id, { enabled: true });
|
|
692
|
+
console.log(`Enabled MCP server: ${server.name}`);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
console.error(`Failed to enable server: ${(error as Error).message}`);
|
|
695
|
+
process.exitCode = 1;
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
mcp
|
|
700
|
+
.command("disable")
|
|
701
|
+
.description("Disable an MCP server")
|
|
702
|
+
.argument("<idOrName>", "Server ID (prefix) or name")
|
|
703
|
+
.action(async (idOrName) => {
|
|
704
|
+
await ensureStorageDir(paths);
|
|
705
|
+
try {
|
|
706
|
+
const servers = await listMcpServers(paths);
|
|
707
|
+
const server = servers.find(
|
|
708
|
+
(s) => s.id.startsWith(idOrName) || s.name.toLowerCase() === idOrName.toLowerCase()
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
if (!server) {
|
|
712
|
+
console.error("Server not found");
|
|
713
|
+
process.exitCode = 1;
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
await updateMcpServer(paths, server.id, { enabled: false });
|
|
718
|
+
console.log(`Disabled MCP server: ${server.name}`);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
console.error(`Failed to disable server: ${(error as Error).message}`);
|
|
721
|
+
process.exitCode = 1;
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
726
|
+
// Provider Commands
|
|
727
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
728
|
+
|
|
729
|
+
async function listProvidersAction(options: { json?: boolean; verbose?: boolean; check?: boolean } = {}): Promise<void> {
|
|
730
|
+
await ensureStorageDir(paths);
|
|
731
|
+
if (options.check !== false) {
|
|
732
|
+
await probeProviderModels(paths);
|
|
733
|
+
}
|
|
734
|
+
const providers = await listProviders(paths);
|
|
735
|
+
if (options.json) {
|
|
736
|
+
printJson(providers);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (providers.length === 0) {
|
|
740
|
+
console.log("No providers imported.");
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const healthMap = await getProviderModelHealthMap(paths);
|
|
744
|
+
const rows = options.verbose
|
|
745
|
+
? providers.map((provider) => ({
|
|
746
|
+
protocol: provider.protocolRaw ?? provider.protocol,
|
|
747
|
+
operations:
|
|
748
|
+
listAdapterOperations(provider.protocol)?.operations.join(",") ?? "-",
|
|
749
|
+
streamOps:
|
|
750
|
+
listAdapterOperations(provider.protocol)?.streamOperations.join(",") ?? "-",
|
|
751
|
+
id: provider.id,
|
|
752
|
+
name: provider.name,
|
|
753
|
+
enabled: provider.enabled ? "yes" : "no",
|
|
754
|
+
tls: provider.insecureTls ? "insecure" : "strict",
|
|
755
|
+
autoInsecureDomains: provider.autoInsecureTlsDomains?.length ?? 0,
|
|
756
|
+
routable: provider.supportsRouting ? "yes" : "no",
|
|
757
|
+
models: provider.models.length,
|
|
758
|
+
scored: provider.models.filter((model) => typeof model.benchmark?.livebench === "number")
|
|
759
|
+
.length,
|
|
760
|
+
hasKey: provider.apiKey || provider.models.some((model) => Boolean(model.apiKey)) ? "yes" : "no",
|
|
761
|
+
health: summarizeProviderHealth(provider.models, healthMap),
|
|
762
|
+
}))
|
|
763
|
+
: providers.map((provider) => ({
|
|
764
|
+
id: provider.id,
|
|
765
|
+
protocol: provider.protocolRaw ?? provider.protocol,
|
|
766
|
+
enabled: provider.enabled ? "yes" : "no",
|
|
767
|
+
tls: provider.insecureTls ? "insecure" : "strict",
|
|
768
|
+
autoInsecureDomains: provider.autoInsecureTlsDomains?.length ?? 0,
|
|
769
|
+
models: provider.models.length,
|
|
770
|
+
scored: provider.models.filter((model) => typeof model.benchmark?.livebench === "number")
|
|
771
|
+
.length,
|
|
772
|
+
hasKey: provider.apiKey || provider.models.some((model) => Boolean(model.apiKey)) ? "yes" : "no",
|
|
773
|
+
health: summarizeProviderHealth(provider.models, healthMap),
|
|
774
|
+
}));
|
|
775
|
+
console.table(rows);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const provider = program
|
|
779
|
+
.command("providers")
|
|
780
|
+
.alias("provider")
|
|
781
|
+
.alias("prov")
|
|
782
|
+
.description("Manage provider catalog and smart pools")
|
|
783
|
+
.action(async () => {
|
|
784
|
+
await listProvidersAction();
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
provider.addHelpText(
|
|
788
|
+
"after",
|
|
789
|
+
`
|
|
790
|
+
Default: \`waypoi providers\` runs \`providers list\`.
|
|
791
|
+
Examples:
|
|
792
|
+
waypoi providers
|
|
793
|
+
waypoi providers show provider-id
|
|
794
|
+
waypoi providers import --registry ./providers/registry.yaml -f .env
|
|
795
|
+
`
|
|
796
|
+
);
|
|
797
|
+
|
|
798
|
+
provider
|
|
799
|
+
.command("import")
|
|
800
|
+
.description("Import providers from a registry and load credentials")
|
|
801
|
+
.option(
|
|
802
|
+
"--registry <path>",
|
|
803
|
+
"Path to providers registry yaml",
|
|
804
|
+
resolveDefaultRegistryPath()
|
|
805
|
+
)
|
|
806
|
+
.option("-f, --env-file <path>", "Path to .env file", ".env")
|
|
807
|
+
.option("--overwrite-auth", "Overwrite stored provider keys with env values")
|
|
808
|
+
.option("--no-rebuild-pools", "Skip automatic smart pool rebuild")
|
|
809
|
+
.action(async (options) => {
|
|
810
|
+
await ensureStorageDir(paths);
|
|
811
|
+
try {
|
|
812
|
+
const result = await importProviders(paths, {
|
|
813
|
+
registryPath: options.registry,
|
|
814
|
+
envFilePath: options.envFile,
|
|
815
|
+
overwriteAuth: Boolean(options.overwriteAuth),
|
|
816
|
+
});
|
|
817
|
+
let rebuilt = 0;
|
|
818
|
+
if (options.rebuildPools !== false) {
|
|
819
|
+
const pools = await rebuildDefaultPools(paths);
|
|
820
|
+
rebuilt = pools.length;
|
|
821
|
+
}
|
|
822
|
+
console.log(`Imported providers: ${result.importedProviders}`);
|
|
823
|
+
console.log(`Imported models: ${result.importedModels}`);
|
|
824
|
+
if (rebuilt > 0) {
|
|
825
|
+
console.log(`Rebuilt pools: ${rebuilt}`);
|
|
826
|
+
}
|
|
827
|
+
if (result.warnings.length > 0) {
|
|
828
|
+
console.log("Warnings:");
|
|
829
|
+
for (const warning of result.warnings) {
|
|
830
|
+
console.log(` - ${warning}`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
} catch (error) {
|
|
834
|
+
console.error(`Provider import failed: ${(error as Error).message}`);
|
|
835
|
+
process.exitCode = 1;
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
provider
|
|
840
|
+
.command("ls")
|
|
841
|
+
.alias("list")
|
|
842
|
+
.description("List providers")
|
|
843
|
+
.option("--json", "Output as JSON")
|
|
844
|
+
.option("--verbose", "Show protocol operation details")
|
|
845
|
+
.option("--no-check", "Skip health check for faster listing")
|
|
846
|
+
.action(async (options) => {
|
|
847
|
+
await listProvidersAction(options);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
provider
|
|
851
|
+
.command("show")
|
|
852
|
+
.description("Show one provider")
|
|
853
|
+
.argument("<providerId>")
|
|
854
|
+
.action(async (providerId) => {
|
|
855
|
+
await ensureStorageDir(paths);
|
|
856
|
+
const providerRecord = await getProviderById(paths, providerId);
|
|
857
|
+
if (!providerRecord) {
|
|
858
|
+
console.error("Provider not found");
|
|
859
|
+
process.exitCode = 1;
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const adapterOps = listAdapterOperations(providerRecord.protocol);
|
|
863
|
+
console.log(
|
|
864
|
+
JSON.stringify(
|
|
865
|
+
{
|
|
866
|
+
...providerRecord,
|
|
867
|
+
supportedOperations: adapterOps?.operations ?? [],
|
|
868
|
+
streamSupportedOperations: adapterOps?.streamOperations ?? [],
|
|
869
|
+
},
|
|
870
|
+
null,
|
|
871
|
+
2
|
|
872
|
+
)
|
|
873
|
+
);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
provider
|
|
877
|
+
.command("update")
|
|
878
|
+
.description("Update provider TLS policy and allowlist")
|
|
879
|
+
.argument("<providerId>")
|
|
880
|
+
.option("--insecure-tls", "Set provider default TLS mode to insecure")
|
|
881
|
+
.option("--strict-tls", "Set provider default TLS mode to strict")
|
|
882
|
+
.option("--auto-insecure-domain <suffix...>", "Set auto-insecure TLS allowlist domains")
|
|
883
|
+
.option("--clear-auto-insecure-domains", "Clear auto-insecure TLS allowlist")
|
|
884
|
+
.option("--no-rebuild", "Skip automatic smart pool rebuild")
|
|
885
|
+
.action(async (providerId, options) => {
|
|
886
|
+
await ensureStorageDir(paths);
|
|
887
|
+
if (options.insecureTls && options.strictTls) {
|
|
888
|
+
console.error("Choose either --insecure-tls or --strict-tls, not both.");
|
|
889
|
+
process.exitCode = 1;
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const patch: Partial<ProviderRecord> = {};
|
|
893
|
+
if (options.insecureTls) {
|
|
894
|
+
patch.insecureTls = true;
|
|
895
|
+
}
|
|
896
|
+
if (options.strictTls) {
|
|
897
|
+
patch.insecureTls = false;
|
|
898
|
+
}
|
|
899
|
+
if (options.clearAutoInsecureDomains) {
|
|
900
|
+
patch.autoInsecureTlsDomains = [];
|
|
901
|
+
} else if (options.autoInsecureDomain) {
|
|
902
|
+
patch.autoInsecureTlsDomains = normalizeDomainSuffixes(options.autoInsecureDomain);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (Object.keys(patch).length === 0) {
|
|
906
|
+
console.error("No provider changes requested.");
|
|
907
|
+
process.exitCode = 1;
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const updated = await updateProvider(paths, providerId, patch);
|
|
912
|
+
if (!updated) {
|
|
913
|
+
console.error("Provider not found");
|
|
914
|
+
process.exitCode = 1;
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (options.rebuild !== false) {
|
|
918
|
+
await rebuildDefaultPools(paths);
|
|
919
|
+
}
|
|
920
|
+
console.log(`Updated provider: ${updated.id}`);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
provider
|
|
924
|
+
.command("models")
|
|
925
|
+
.description("List models for a provider")
|
|
926
|
+
.argument("<providerId>")
|
|
927
|
+
.option("--free", "Only free models")
|
|
928
|
+
.option("--modality <modality>", "Filter by modality (e.g., text-to-text,image-to-text)")
|
|
929
|
+
.option("--json", "Output as JSON")
|
|
930
|
+
.action(async (providerId, options) => {
|
|
931
|
+
await ensureStorageDir(paths);
|
|
932
|
+
const providerRecord = await getProviderById(paths, providerId);
|
|
933
|
+
if (!providerRecord) {
|
|
934
|
+
console.error("Provider not found");
|
|
935
|
+
process.exitCode = 1;
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const modality = typeof options.modality === "string" ? options.modality.trim() : undefined;
|
|
939
|
+
const filtered = providerRecord.models.filter((model) => {
|
|
940
|
+
if (options.free && !model.free) {
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
if (modality && !model.modalities.includes(modality)) {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
return true;
|
|
947
|
+
});
|
|
948
|
+
if (options.json) {
|
|
949
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
console.table(
|
|
953
|
+
filtered.map((model) => ({
|
|
954
|
+
id: model.modelId,
|
|
955
|
+
upstream: model.upstreamModel,
|
|
956
|
+
baseUrl: model.baseUrl ?? providerRecord.baseUrl,
|
|
957
|
+
enabled: model.enabled === false ? "no" : "yes",
|
|
958
|
+
free: model.free ? "yes" : "no",
|
|
959
|
+
modalities: model.modalities.join(","),
|
|
960
|
+
livebench: model.benchmark?.livebench ?? "-",
|
|
961
|
+
}))
|
|
962
|
+
);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
const providerModel = provider
|
|
966
|
+
.command("model")
|
|
967
|
+
.description("Manage provider-owned models");
|
|
968
|
+
|
|
969
|
+
providerModel
|
|
970
|
+
.command("ls")
|
|
971
|
+
.description("List models for a provider")
|
|
972
|
+
.argument("<providerId>")
|
|
973
|
+
.option("--json", "Output as JSON")
|
|
974
|
+
.option("--enabled", "Only show enabled models")
|
|
975
|
+
.option("--modality <modality>", "Filter by modality (e.g. text-to-text,image-to-text)")
|
|
976
|
+
.option("--verbose", "Show full model metadata")
|
|
977
|
+
.option("--no-check", "Skip health check for faster listing")
|
|
978
|
+
.action(async (providerId, options) => {
|
|
979
|
+
await ensureStorageDir(paths);
|
|
980
|
+
if (options.check !== false) {
|
|
981
|
+
await probeProviderModels(paths);
|
|
982
|
+
}
|
|
983
|
+
const healthMap = await getProviderModelHealthMap(paths);
|
|
984
|
+
const models = await listProviderModels(paths, providerId);
|
|
985
|
+
if (!models) {
|
|
986
|
+
console.error("Provider not found");
|
|
987
|
+
process.exitCode = 1;
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const modality = typeof options.modality === "string" ? options.modality.trim() : undefined;
|
|
992
|
+
const filtered = models.filter((model) => {
|
|
993
|
+
if (options.enabled && model.enabled === false) {
|
|
994
|
+
return false;
|
|
995
|
+
}
|
|
996
|
+
if (modality && !model.modalities.includes(modality)) {
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
return true;
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
if (options.json) {
|
|
1003
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const rows = options.verbose
|
|
1008
|
+
? filtered.map((model) => ({
|
|
1009
|
+
providerModelId: model.providerModelId,
|
|
1010
|
+
modelId: model.modelId,
|
|
1011
|
+
upstreamModel: model.upstreamModel,
|
|
1012
|
+
enabled: model.enabled === false ? "no" : "yes",
|
|
1013
|
+
tls: model.insecureTls === undefined ? "inherit" : model.insecureTls ? "insecure" : "strict",
|
|
1014
|
+
endpointType: model.endpointType,
|
|
1015
|
+
baseUrl: model.baseUrl ?? "-",
|
|
1016
|
+
aliases: (model.aliases ?? []).join(","),
|
|
1017
|
+
free: model.free ? "yes" : "no",
|
|
1018
|
+
livebench: model.benchmark?.livebench ?? "-",
|
|
1019
|
+
status: healthMap[model.providerModelId]?.status ?? "-",
|
|
1020
|
+
latency: formatLatency(healthMap[model.providerModelId]?.latencyMsEwma),
|
|
1021
|
+
lastStatus: healthMap[model.providerModelId]?.lastStatusCode ?? "-",
|
|
1022
|
+
lastError: healthMap[model.providerModelId]?.lastError ?? "-",
|
|
1023
|
+
}))
|
|
1024
|
+
: filtered.map((model) => ({
|
|
1025
|
+
id: model.modelId,
|
|
1026
|
+
enabled: model.enabled === false ? "no" : "yes",
|
|
1027
|
+
tls: model.insecureTls === undefined ? "inherit" : model.insecureTls ? "insecure" : "strict",
|
|
1028
|
+
type: model.endpointType,
|
|
1029
|
+
aliases: (model.aliases ?? []).length,
|
|
1030
|
+
livebench: model.benchmark?.livebench ?? "-",
|
|
1031
|
+
status: healthMap[model.providerModelId]?.status ?? "-",
|
|
1032
|
+
latency: formatLatency(healthMap[model.providerModelId]?.latencyMsEwma),
|
|
1033
|
+
lastStatus: healthMap[model.providerModelId]?.lastStatusCode ?? "-",
|
|
1034
|
+
lastError: healthMap[model.providerModelId]?.lastError ?? "-",
|
|
1035
|
+
}));
|
|
1036
|
+
console.table(rows);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
providerModel
|
|
1040
|
+
.command("show")
|
|
1041
|
+
.description("Show one model from a provider")
|
|
1042
|
+
.argument("<providerId>")
|
|
1043
|
+
.argument("<modelRef>")
|
|
1044
|
+
.action(async (providerId, modelRef) => {
|
|
1045
|
+
await ensureStorageDir(paths);
|
|
1046
|
+
const model = await getProviderModel(paths, providerId, modelRef);
|
|
1047
|
+
if (!model) {
|
|
1048
|
+
console.error("Model not found");
|
|
1049
|
+
process.exitCode = 1;
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
console.log(JSON.stringify(model, null, 2));
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
providerModel
|
|
1056
|
+
.command("add")
|
|
1057
|
+
.description("Add a model under a provider")
|
|
1058
|
+
.argument("<providerId>")
|
|
1059
|
+
.requiredOption("--model-id <id>", "Provider model ID suffix")
|
|
1060
|
+
.requiredOption("--upstream <name>", "Upstream model name")
|
|
1061
|
+
.requiredOption("--base-url <url>", "Base URL for this model")
|
|
1062
|
+
.option("--api-key <key>", "API key for this model")
|
|
1063
|
+
.option("--insecure-tls", "Allow self-signed TLS certificates for this model")
|
|
1064
|
+
.option("--endpoint-type <type>", "Endpoint type (llm|diffusion|audio|embedding)", "llm")
|
|
1065
|
+
.option("--capability <spec...>", "Capability spec, e.g. text->text or text+image->text")
|
|
1066
|
+
.option("--alias <alias...>", "Legacy/public aliases")
|
|
1067
|
+
.option("--free", "Mark model as free")
|
|
1068
|
+
.option("--no-free", "Mark model as not free")
|
|
1069
|
+
.option("--disabled", "Add model in disabled state")
|
|
1070
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
1071
|
+
.action(async (providerId, options) => {
|
|
1072
|
+
await ensureStorageDir(paths);
|
|
1073
|
+
const providerRecord = await getProviderById(paths, providerId);
|
|
1074
|
+
if (!providerRecord) {
|
|
1075
|
+
console.error("Provider not found");
|
|
1076
|
+
process.exitCode = 1;
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const endpointType = normalizeType(options.endpointType);
|
|
1081
|
+
const capabilities = options.capability
|
|
1082
|
+
? parseCapabilitySpecs(options.capability)
|
|
1083
|
+
: defaultCapabilitiesForEndpointType(endpointType);
|
|
1084
|
+
const modelId = String(options.modelId).trim();
|
|
1085
|
+
const providerModelId = canonicalProviderModelId(providerId, modelId);
|
|
1086
|
+
const modelRecord: ProviderModelRecord = {
|
|
1087
|
+
providerModelId,
|
|
1088
|
+
providerId,
|
|
1089
|
+
modelId,
|
|
1090
|
+
upstreamModel: String(options.upstream).trim(),
|
|
1091
|
+
baseUrl: String(options.baseUrl).trim(),
|
|
1092
|
+
apiKey: options.apiKey,
|
|
1093
|
+
insecureTls: options.insecureTls ? true : undefined,
|
|
1094
|
+
enabled: options.disabled ? false : true,
|
|
1095
|
+
aliases: normalizeAliasList(options.alias ?? []),
|
|
1096
|
+
free: options.free !== false,
|
|
1097
|
+
modalities: capabilitiesToModalities(capabilities),
|
|
1098
|
+
capabilities,
|
|
1099
|
+
endpointType,
|
|
1100
|
+
};
|
|
1101
|
+
const result = await upsertProviderModel(paths, providerId, modelRecord);
|
|
1102
|
+
if (!result) {
|
|
1103
|
+
console.error("Failed to add model");
|
|
1104
|
+
process.exitCode = 1;
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (options.rebuild !== false) {
|
|
1108
|
+
await rebuildDefaultPools(paths);
|
|
1109
|
+
}
|
|
1110
|
+
console.log(`Model ${result.created ? "added" : "updated"}: ${providerModelId}`);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
providerModel
|
|
1114
|
+
.command("update")
|
|
1115
|
+
.description("Update a provider model")
|
|
1116
|
+
.argument("<providerId>")
|
|
1117
|
+
.argument("<modelRef>")
|
|
1118
|
+
.option("--upstream <name>", "Set upstream model")
|
|
1119
|
+
.option("--base-url <url>", "Set base URL")
|
|
1120
|
+
.option("--clear-base-url", "Clear model-specific base URL override")
|
|
1121
|
+
.option("--api-key <key>", "Set API key")
|
|
1122
|
+
.option("--clear-api-key", "Clear model-specific API key")
|
|
1123
|
+
.option("--insecure-tls", "Enable insecure TLS for this model")
|
|
1124
|
+
.option("--clear-insecure-tls", "Clear model TLS override and inherit provider setting")
|
|
1125
|
+
.option("--endpoint-type <type>", "Endpoint type")
|
|
1126
|
+
.option("--capability <spec...>", "Replace capabilities")
|
|
1127
|
+
.option("--alias <alias...>", "Set aliases")
|
|
1128
|
+
.option("--free", "Set free=true")
|
|
1129
|
+
.option("--not-free", "Set free=false")
|
|
1130
|
+
.option("--enabled", "Set enabled=true")
|
|
1131
|
+
.option("--disabled", "Set enabled=false")
|
|
1132
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
1133
|
+
.action(async (providerId, modelRef, options) => {
|
|
1134
|
+
await ensureStorageDir(paths);
|
|
1135
|
+
const patch: Partial<ProviderModelRecord> = {};
|
|
1136
|
+
if (typeof options.upstream === "string") {
|
|
1137
|
+
patch.upstreamModel = options.upstream.trim();
|
|
1138
|
+
}
|
|
1139
|
+
if (typeof options.baseUrl === "string") {
|
|
1140
|
+
patch.baseUrl = options.baseUrl.trim();
|
|
1141
|
+
}
|
|
1142
|
+
if (options.clearBaseUrl) {
|
|
1143
|
+
patch.baseUrl = undefined;
|
|
1144
|
+
}
|
|
1145
|
+
if (typeof options.apiKey === "string") {
|
|
1146
|
+
patch.apiKey = options.apiKey;
|
|
1147
|
+
}
|
|
1148
|
+
if (options.clearApiKey) {
|
|
1149
|
+
patch.apiKey = undefined;
|
|
1150
|
+
}
|
|
1151
|
+
if (options.insecureTls) {
|
|
1152
|
+
patch.insecureTls = true;
|
|
1153
|
+
}
|
|
1154
|
+
if (options.clearInsecureTls) {
|
|
1155
|
+
patch.insecureTls = undefined;
|
|
1156
|
+
}
|
|
1157
|
+
if (typeof options.endpointType === "string") {
|
|
1158
|
+
patch.endpointType = normalizeType(options.endpointType);
|
|
1159
|
+
}
|
|
1160
|
+
if (options.capability) {
|
|
1161
|
+
const capabilities = parseCapabilitySpecs(options.capability);
|
|
1162
|
+
patch.capabilities = capabilities;
|
|
1163
|
+
patch.modalities = capabilitiesToModalities(capabilities);
|
|
1164
|
+
}
|
|
1165
|
+
if (options.alias) {
|
|
1166
|
+
patch.aliases = normalizeAliasList(options.alias);
|
|
1167
|
+
}
|
|
1168
|
+
if (options.free) {
|
|
1169
|
+
patch.free = true;
|
|
1170
|
+
}
|
|
1171
|
+
if (options.notFree) {
|
|
1172
|
+
patch.free = false;
|
|
1173
|
+
}
|
|
1174
|
+
if (options.enabled) {
|
|
1175
|
+
patch.enabled = true;
|
|
1176
|
+
}
|
|
1177
|
+
if (options.disabled) {
|
|
1178
|
+
patch.enabled = false;
|
|
1179
|
+
}
|
|
1180
|
+
if (Object.keys(patch).length === 0) {
|
|
1181
|
+
console.error("No changes requested.");
|
|
1182
|
+
process.exitCode = 1;
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const updated = await updateProviderModel(paths, providerId, modelRef, patch);
|
|
1187
|
+
if (!updated) {
|
|
1188
|
+
console.error("Model not found");
|
|
1189
|
+
process.exitCode = 1;
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if (options.rebuild !== false) {
|
|
1193
|
+
await rebuildDefaultPools(paths);
|
|
1194
|
+
}
|
|
1195
|
+
console.log(`Updated model: ${updated.providerModelId}`);
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
providerModel
|
|
1199
|
+
.command("rm")
|
|
1200
|
+
.description("Remove a provider model")
|
|
1201
|
+
.argument("<providerId>")
|
|
1202
|
+
.argument("<modelRef>")
|
|
1203
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
1204
|
+
.action(async (providerId, modelRef, options) => {
|
|
1205
|
+
await ensureStorageDir(paths);
|
|
1206
|
+
const removed = await deleteProviderModel(paths, providerId, modelRef);
|
|
1207
|
+
if (!removed) {
|
|
1208
|
+
console.error("Model not found");
|
|
1209
|
+
process.exitCode = 1;
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
if (options.rebuild !== false) {
|
|
1213
|
+
await rebuildDefaultPools(paths);
|
|
1214
|
+
}
|
|
1215
|
+
console.log(`Removed model: ${removed.providerModelId}`);
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
providerModel
|
|
1219
|
+
.command("enable")
|
|
1220
|
+
.description("Enable a provider model")
|
|
1221
|
+
.argument("<providerId>")
|
|
1222
|
+
.argument("<modelRef>")
|
|
1223
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
1224
|
+
.action(async (providerId, modelRef, options) => {
|
|
1225
|
+
await ensureStorageDir(paths);
|
|
1226
|
+
const model = await setProviderModelEnabled(paths, providerId, modelRef, true);
|
|
1227
|
+
if (!model) {
|
|
1228
|
+
console.error("Model not found");
|
|
1229
|
+
process.exitCode = 1;
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
if (options.rebuild !== false) {
|
|
1233
|
+
await rebuildDefaultPools(paths);
|
|
1234
|
+
}
|
|
1235
|
+
console.log(`Enabled model: ${model.providerModelId}`);
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
providerModel
|
|
1239
|
+
.command("disable")
|
|
1240
|
+
.description("Disable a provider model")
|
|
1241
|
+
.argument("<providerId>")
|
|
1242
|
+
.argument("<modelRef>")
|
|
1243
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
1244
|
+
.action(async (providerId, modelRef, options) => {
|
|
1245
|
+
await ensureStorageDir(paths);
|
|
1246
|
+
const model = await setProviderModelEnabled(paths, providerId, modelRef, false);
|
|
1247
|
+
if (!model) {
|
|
1248
|
+
console.error("Model not found");
|
|
1249
|
+
process.exitCode = 1;
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
if (options.rebuild !== false) {
|
|
1253
|
+
await rebuildDefaultPools(paths);
|
|
1254
|
+
}
|
|
1255
|
+
console.log(`Disabled model: ${model.providerModelId}`);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
providerModel
|
|
1259
|
+
.command("set-key")
|
|
1260
|
+
.description("Set plaintext API key for a provider model")
|
|
1261
|
+
.argument("<providerId>")
|
|
1262
|
+
.argument("<modelRef>")
|
|
1263
|
+
.option("--api-key <key>", "API key value")
|
|
1264
|
+
.option("--env-var <name>", "Read API key from environment variable")
|
|
1265
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
1266
|
+
.action(async (providerId, modelRef, options) => {
|
|
1267
|
+
await ensureStorageDir(paths);
|
|
1268
|
+
let apiKey: string | undefined = options.apiKey;
|
|
1269
|
+
if (!apiKey && options.envVar) {
|
|
1270
|
+
apiKey = process.env[String(options.envVar)] ?? undefined;
|
|
1271
|
+
if (!apiKey) {
|
|
1272
|
+
console.error(`Environment variable '${options.envVar}' is not set.`);
|
|
1273
|
+
process.exitCode = 1;
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
if (!apiKey) {
|
|
1278
|
+
console.error("Provide --api-key or --env-var.");
|
|
1279
|
+
process.exitCode = 1;
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const model = await setProviderModelApiKey(paths, providerId, modelRef, apiKey);
|
|
1283
|
+
if (!model) {
|
|
1284
|
+
console.error("Model not found");
|
|
1285
|
+
process.exitCode = 1;
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
if (options.rebuild !== false) {
|
|
1289
|
+
await rebuildDefaultPools(paths);
|
|
1290
|
+
}
|
|
1291
|
+
console.log(`Updated key for model: ${model.providerModelId}`);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
provider
|
|
1295
|
+
.command("enable")
|
|
1296
|
+
.description("Enable a provider")
|
|
1297
|
+
.argument("<providerId>")
|
|
1298
|
+
.action(async (providerId) => {
|
|
1299
|
+
await ensureStorageDir(paths);
|
|
1300
|
+
const updated = await setProviderEnabled(paths, providerId, true);
|
|
1301
|
+
if (!updated) {
|
|
1302
|
+
console.error("Provider not found");
|
|
1303
|
+
process.exitCode = 1;
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
await rebuildDefaultPools(paths);
|
|
1307
|
+
console.log(`Enabled provider: ${updated.id}`);
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
provider
|
|
1311
|
+
.command("disable")
|
|
1312
|
+
.description("Disable a provider")
|
|
1313
|
+
.argument("<providerId>")
|
|
1314
|
+
.action(async (providerId) => {
|
|
1315
|
+
await ensureStorageDir(paths);
|
|
1316
|
+
const updated = await setProviderEnabled(paths, providerId, false);
|
|
1317
|
+
if (!updated) {
|
|
1318
|
+
console.error("Provider not found");
|
|
1319
|
+
process.exitCode = 1;
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
await rebuildDefaultPools(paths);
|
|
1323
|
+
console.log(`Disabled provider: ${updated.id}`);
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
provider
|
|
1327
|
+
.command("migrate-endpoints")
|
|
1328
|
+
.description("Copy matching endpoints into a provider and disable source endpoints")
|
|
1329
|
+
.requiredOption("--provider <id>", "Destination provider ID (e.g. pcai)")
|
|
1330
|
+
.option("--match-domain <domain>", "Hostname suffix to migrate (e.g. ai-application.stjude.org)")
|
|
1331
|
+
.option("--all", "Migrate all endpoints (ignore domain filter)")
|
|
1332
|
+
.option("--protocol <protocol>", "Protocol for destination provider", "openai")
|
|
1333
|
+
.action(async (options) => {
|
|
1334
|
+
await ensureStorageDir(paths);
|
|
1335
|
+
|
|
1336
|
+
const providerId = String(options.provider).trim();
|
|
1337
|
+
const domain = typeof options.matchDomain === "string" ? options.matchDomain.trim().toLowerCase() : "";
|
|
1338
|
+
const includeAll = options.all === true;
|
|
1339
|
+
const protocol = normalizeProviderProtocol(String(options.protocol));
|
|
1340
|
+
const now = new Date().toISOString();
|
|
1341
|
+
const warnings: string[] = [];
|
|
1342
|
+
let skippedEndpoints = 0;
|
|
1343
|
+
let migratedModels = 0;
|
|
1344
|
+
let createdModels = 0;
|
|
1345
|
+
let updatedModels = 0;
|
|
1346
|
+
let disabledEndpoints = 0;
|
|
1347
|
+
const endpointIdsToDisable = new Set<string>();
|
|
1348
|
+
|
|
1349
|
+
if (!providerId) {
|
|
1350
|
+
console.error("--provider is required");
|
|
1351
|
+
process.exitCode = 1;
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (!includeAll && !domain) {
|
|
1355
|
+
console.error("Provide --match-domain <domain> or use --all.");
|
|
1356
|
+
process.exitCode = 1;
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const allEndpoints = await listEndpoints(paths);
|
|
1361
|
+
const matchedEndpoints = allEndpoints.filter((endpoint) => {
|
|
1362
|
+
if (includeAll) {
|
|
1363
|
+
return true;
|
|
1364
|
+
}
|
|
1365
|
+
try {
|
|
1366
|
+
const host = new URL(endpoint.baseUrl).hostname.toLowerCase();
|
|
1367
|
+
return hostMatchesDomain(host, domain);
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
warnings.push(`Skipped endpoint '${endpoint.name}': invalid baseUrl (${(error as Error).message})`);
|
|
1370
|
+
skippedEndpoints += 1;
|
|
1371
|
+
return false;
|
|
1372
|
+
}
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
if (matchedEndpoints.length === 0) {
|
|
1376
|
+
console.log(
|
|
1377
|
+
includeAll
|
|
1378
|
+
? "No endpoints found to migrate."
|
|
1379
|
+
: `No endpoints matched domain suffix '${domain}'.`
|
|
1380
|
+
);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const existingProvider = await getProviderById(paths, providerId);
|
|
1385
|
+
const providerSeed: ProviderRecord = {
|
|
1386
|
+
id: providerId,
|
|
1387
|
+
name: existingProvider?.name ?? providerId.toUpperCase(),
|
|
1388
|
+
description: existingProvider?.description ?? `Migrated endpoints for ${includeAll ? "all legacy endpoints" : domain}`,
|
|
1389
|
+
docs: existingProvider?.docs,
|
|
1390
|
+
protocol,
|
|
1391
|
+
protocolRaw: options.protocol,
|
|
1392
|
+
protocolConfig: existingProvider?.protocolConfig,
|
|
1393
|
+
baseUrl: existingProvider?.baseUrl ?? matchedEndpoints[0].baseUrl,
|
|
1394
|
+
enabled: existingProvider?.enabled ?? true,
|
|
1395
|
+
supportsRouting: hasProtocolAdapter(protocol),
|
|
1396
|
+
auth: existingProvider?.auth ?? { type: "bearer" },
|
|
1397
|
+
envVar: existingProvider?.envVar,
|
|
1398
|
+
apiKey: existingProvider?.apiKey,
|
|
1399
|
+
limits: existingProvider?.limits,
|
|
1400
|
+
models: existingProvider?.models ?? [],
|
|
1401
|
+
warnings: existingProvider?.warnings,
|
|
1402
|
+
importedAt: existingProvider?.importedAt ?? now,
|
|
1403
|
+
};
|
|
1404
|
+
|
|
1405
|
+
await upsertProvider(paths, providerSeed);
|
|
1406
|
+
|
|
1407
|
+
for (const endpoint of matchedEndpoints) {
|
|
1408
|
+
if (endpoint.models.length === 0) {
|
|
1409
|
+
warnings.push(`Endpoint '${endpoint.name}' has no models; skipped.`);
|
|
1410
|
+
skippedEndpoints += 1;
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
let migratedFromEndpoint = 0;
|
|
1415
|
+
for (const mapping of endpoint.models) {
|
|
1416
|
+
const capabilities = getModelCapabilitiesForEndpoint(endpoint.type, mapping);
|
|
1417
|
+
const canonicalId = canonicalProviderModelId(providerId, mapping.publicName);
|
|
1418
|
+
const aliases = normalizeAliasList([mapping.publicName, ...(existingProvider?.models ?? [])
|
|
1419
|
+
.filter((m) => m.modelId === mapping.publicName)
|
|
1420
|
+
.flatMap((m) => m.aliases ?? [])]);
|
|
1421
|
+
const modelRecord: ProviderModelRecord = {
|
|
1422
|
+
providerModelId: canonicalId,
|
|
1423
|
+
providerId,
|
|
1424
|
+
modelId: mapping.publicName,
|
|
1425
|
+
upstreamModel: mapping.upstreamModel,
|
|
1426
|
+
baseUrl: endpoint.baseUrl,
|
|
1427
|
+
apiKey: endpoint.apiKey,
|
|
1428
|
+
insecureTls: endpoint.insecureTls,
|
|
1429
|
+
enabled: true,
|
|
1430
|
+
aliases,
|
|
1431
|
+
free: true,
|
|
1432
|
+
modalities: capabilitiesToModalities(capabilities),
|
|
1433
|
+
capabilities,
|
|
1434
|
+
endpointType: endpoint.type,
|
|
1435
|
+
};
|
|
1436
|
+
const result = await upsertProviderModel(paths, providerId, modelRecord);
|
|
1437
|
+
if (!result) {
|
|
1438
|
+
warnings.push(`Failed to write model '${mapping.publicName}' into provider '${providerId}'.`);
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
migratedModels += 1;
|
|
1442
|
+
migratedFromEndpoint += 1;
|
|
1443
|
+
if (result.created) {
|
|
1444
|
+
createdModels += 1;
|
|
1445
|
+
} else {
|
|
1446
|
+
updatedModels += 1;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
if (migratedFromEndpoint > 0) {
|
|
1450
|
+
endpointIdsToDisable.add(endpoint.id);
|
|
1451
|
+
} else {
|
|
1452
|
+
warnings.push(`Endpoint '${endpoint.name}' had no models migrated; left enabled.`);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
for (const endpoint of matchedEndpoints) {
|
|
1457
|
+
if (!endpointIdsToDisable.has(endpoint.id)) {
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
if (endpoint.disabled) {
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
const updatedEndpoint = await setEndpointDisabled(paths, endpoint.id, true);
|
|
1464
|
+
if (updatedEndpoint) {
|
|
1465
|
+
disabledEndpoints += 1;
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const pools = await rebuildDefaultPools(paths);
|
|
1470
|
+
const reportPath = writeMigrationReport(paths.baseDir, {
|
|
1471
|
+
timestamp: now,
|
|
1472
|
+
providerId,
|
|
1473
|
+
includeAll,
|
|
1474
|
+
domain: domain || undefined,
|
|
1475
|
+
matchedEndpoints: matchedEndpoints.length,
|
|
1476
|
+
migratedModels,
|
|
1477
|
+
createdModels,
|
|
1478
|
+
updatedModels,
|
|
1479
|
+
disabledEndpoints,
|
|
1480
|
+
skippedEndpoints,
|
|
1481
|
+
warnings,
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
console.log(`Migrated provider: ${providerId}`);
|
|
1485
|
+
console.log(`Matched endpoints: ${matchedEndpoints.length}`);
|
|
1486
|
+
console.log(`Migrated models: ${migratedModels} (created ${createdModels}, updated ${updatedModels})`);
|
|
1487
|
+
console.log(`Disabled source endpoints: ${disabledEndpoints}`);
|
|
1488
|
+
console.log(`Rebuilt pools: ${pools.length}`);
|
|
1489
|
+
console.log(`Migration report: ${reportPath}`);
|
|
1490
|
+
|
|
1491
|
+
if (warnings.length > 0) {
|
|
1492
|
+
console.log("Warnings:");
|
|
1493
|
+
for (const warning of warnings) {
|
|
1494
|
+
console.log(` - ${warning}`);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
if (skippedEndpoints > 0) {
|
|
1498
|
+
console.log(`Skipped endpoints: ${skippedEndpoints}`);
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
provider
|
|
1503
|
+
.command("pools")
|
|
1504
|
+
.description("List smart pools")
|
|
1505
|
+
.option("--json", "Output as JSON")
|
|
1506
|
+
.action(async (options) => {
|
|
1507
|
+
await ensureStorageDir(paths);
|
|
1508
|
+
const pools = await listPools(paths);
|
|
1509
|
+
if (options.json) {
|
|
1510
|
+
console.log(JSON.stringify(pools, null, 2));
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
if (pools.length === 0) {
|
|
1514
|
+
console.log("No pools found.");
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
console.table(
|
|
1518
|
+
pools.map((pool) => ({
|
|
1519
|
+
id: pool.id,
|
|
1520
|
+
aliases: pool.aliases.join(","),
|
|
1521
|
+
candidates: pool.candidates.length,
|
|
1522
|
+
strategy: pool.strategy,
|
|
1523
|
+
}))
|
|
1524
|
+
);
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
type ResolvedModelTarget = {
|
|
1528
|
+
providerId: string;
|
|
1529
|
+
modelId: string;
|
|
1530
|
+
model: ProviderModelRecord;
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
async function resolveModelTarget(modelRef: string): Promise<ResolvedModelTarget | null> {
|
|
1534
|
+
const parsed = parseModelRef(modelRef);
|
|
1535
|
+
if (parsed.providerId) {
|
|
1536
|
+
const model = await getProviderModel(paths, parsed.providerId, parsed.modelId);
|
|
1537
|
+
if (!model) {
|
|
1538
|
+
printErrorWithSuggestion(
|
|
1539
|
+
`Model not found: ${parsed.providerId}/${parsed.modelId}`,
|
|
1540
|
+
[
|
|
1541
|
+
`Try: waypoi models ${parsed.providerId}`,
|
|
1542
|
+
"List providers: waypoi providers",
|
|
1543
|
+
]
|
|
1544
|
+
);
|
|
1545
|
+
process.exitCode = 1;
|
|
1546
|
+
return null;
|
|
1547
|
+
}
|
|
1548
|
+
return {
|
|
1549
|
+
providerId: parsed.providerId,
|
|
1550
|
+
modelId: parsed.modelId,
|
|
1551
|
+
model,
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const providers = await listProviders(paths);
|
|
1556
|
+
const matches: ResolvedModelTarget[] = [];
|
|
1557
|
+
for (const providerEntry of providers) {
|
|
1558
|
+
for (const model of providerEntry.models) {
|
|
1559
|
+
if (
|
|
1560
|
+
model.modelId === parsed.modelId ||
|
|
1561
|
+
model.providerModelId === parsed.modelId ||
|
|
1562
|
+
(model.aliases ?? []).includes(parsed.modelId)
|
|
1563
|
+
) {
|
|
1564
|
+
matches.push({
|
|
1565
|
+
providerId: providerEntry.id,
|
|
1566
|
+
modelId: model.modelId,
|
|
1567
|
+
model,
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
if (matches.length === 0) {
|
|
1574
|
+
printErrorWithSuggestion(
|
|
1575
|
+
`Unknown model '${parsed.modelId}'`,
|
|
1576
|
+
[
|
|
1577
|
+
"Try: waypoi models",
|
|
1578
|
+
"List providers: waypoi providers",
|
|
1579
|
+
]
|
|
1580
|
+
);
|
|
1581
|
+
process.exitCode = 1;
|
|
1582
|
+
return null;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (matches.length > 1) {
|
|
1586
|
+
const candidates = matches
|
|
1587
|
+
.map((entry) => ` - waypoi models show ${entry.providerId}/${entry.modelId}`)
|
|
1588
|
+
.slice(0, 10);
|
|
1589
|
+
printErrorWithSuggestion(
|
|
1590
|
+
`Model '${parsed.modelId}' is ambiguous across providers.`,
|
|
1591
|
+
[
|
|
1592
|
+
"Use a provider-qualified model reference:",
|
|
1593
|
+
...candidates,
|
|
1594
|
+
]
|
|
1595
|
+
);
|
|
1596
|
+
process.exitCode = 1;
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
return matches[0];
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
async function listModelsAction(
|
|
1604
|
+
providerId: string | undefined,
|
|
1605
|
+
options: {
|
|
1606
|
+
json?: boolean;
|
|
1607
|
+
enabled?: boolean;
|
|
1608
|
+
modality?: string;
|
|
1609
|
+
verbose?: boolean;
|
|
1610
|
+
check?: boolean;
|
|
1611
|
+
}
|
|
1612
|
+
): Promise<void> {
|
|
1613
|
+
await ensureStorageDir(paths);
|
|
1614
|
+
if (options.check !== false) {
|
|
1615
|
+
await probeProviderModels(paths);
|
|
1616
|
+
}
|
|
1617
|
+
const healthMap = await getProviderModelHealthMap(paths);
|
|
1618
|
+
const modality = typeof options.modality === "string" ? options.modality.trim() : undefined;
|
|
1619
|
+
|
|
1620
|
+
if (providerId) {
|
|
1621
|
+
const models = await listProviderModels(paths, providerId);
|
|
1622
|
+
if (!models) {
|
|
1623
|
+
printErrorWithSuggestion(
|
|
1624
|
+
`Provider not found: ${providerId}`,
|
|
1625
|
+
["List providers: waypoi providers"]
|
|
1626
|
+
);
|
|
1627
|
+
process.exitCode = 1;
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
const filtered = models.filter((model) => {
|
|
1631
|
+
if (options.enabled && model.enabled === false) {
|
|
1632
|
+
return false;
|
|
1633
|
+
}
|
|
1634
|
+
if (modality && !model.modalities.includes(modality)) {
|
|
1635
|
+
return false;
|
|
1636
|
+
}
|
|
1637
|
+
return true;
|
|
1638
|
+
});
|
|
1639
|
+
if (options.json) {
|
|
1640
|
+
printJson(filtered);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const rows = options.verbose
|
|
1644
|
+
? filtered.map((model) => ({
|
|
1645
|
+
provider: providerId,
|
|
1646
|
+
providerModelId: model.providerModelId,
|
|
1647
|
+
modelId: model.modelId,
|
|
1648
|
+
upstreamModel: model.upstreamModel,
|
|
1649
|
+
enabled: model.enabled === false ? "no" : "yes",
|
|
1650
|
+
tls: model.insecureTls === undefined ? "inherit" : model.insecureTls ? "insecure" : "strict",
|
|
1651
|
+
endpointType: model.endpointType,
|
|
1652
|
+
baseUrl: model.baseUrl ?? "-",
|
|
1653
|
+
aliases: (model.aliases ?? []).join(","),
|
|
1654
|
+
free: model.free ? "yes" : "no",
|
|
1655
|
+
livebench: model.benchmark?.livebench ?? "-",
|
|
1656
|
+
status: healthMap[model.providerModelId]?.status ?? "-",
|
|
1657
|
+
latency: formatLatency(healthMap[model.providerModelId]?.latencyMsEwma),
|
|
1658
|
+
lastStatus: healthMap[model.providerModelId]?.lastStatusCode ?? "-",
|
|
1659
|
+
lastError: healthMap[model.providerModelId]?.lastError ?? "-",
|
|
1660
|
+
}))
|
|
1661
|
+
: filtered.map((model) => ({
|
|
1662
|
+
provider: providerId,
|
|
1663
|
+
id: model.modelId,
|
|
1664
|
+
enabled: model.enabled === false ? "no" : "yes",
|
|
1665
|
+
tls: model.insecureTls === undefined ? "inherit" : model.insecureTls ? "insecure" : "strict",
|
|
1666
|
+
type: model.endpointType,
|
|
1667
|
+
aliases: (model.aliases ?? []).length,
|
|
1668
|
+
livebench: model.benchmark?.livebench ?? "-",
|
|
1669
|
+
status: healthMap[model.providerModelId]?.status ?? "-",
|
|
1670
|
+
latency: formatLatency(healthMap[model.providerModelId]?.latencyMsEwma),
|
|
1671
|
+
}));
|
|
1672
|
+
console.table(rows);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
const providers = await listProviders(paths);
|
|
1677
|
+
const flattened = providers.flatMap((providerEntry) =>
|
|
1678
|
+
providerEntry.models.map((model) => ({ providerId: providerEntry.id, model }))
|
|
1679
|
+
);
|
|
1680
|
+
const filtered = flattened.filter(({ model }) => {
|
|
1681
|
+
if (options.enabled && model.enabled === false) {
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
if (modality && !model.modalities.includes(modality)) {
|
|
1685
|
+
return false;
|
|
1686
|
+
}
|
|
1687
|
+
return true;
|
|
1688
|
+
});
|
|
1689
|
+
if (options.json) {
|
|
1690
|
+
printJson(
|
|
1691
|
+
filtered.map(({ providerId, model }) => ({
|
|
1692
|
+
...model,
|
|
1693
|
+
providerId,
|
|
1694
|
+
}))
|
|
1695
|
+
);
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
const rows = filtered.map(({ providerId, model }) => ({
|
|
1699
|
+
provider: providerId,
|
|
1700
|
+
model: model.modelId,
|
|
1701
|
+
enabled: model.enabled === false ? "no" : "yes",
|
|
1702
|
+
type: model.endpointType,
|
|
1703
|
+
aliases: (model.aliases ?? []).length,
|
|
1704
|
+
livebench: model.benchmark?.livebench ?? "-",
|
|
1705
|
+
status: healthMap[model.providerModelId]?.status ?? "-",
|
|
1706
|
+
latency: formatLatency(healthMap[model.providerModelId]?.latencyMsEwma),
|
|
1707
|
+
}));
|
|
1708
|
+
console.table(rows);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const models = program
|
|
1712
|
+
.command("models")
|
|
1713
|
+
.alias("model")
|
|
1714
|
+
.description("List and manage provider-owned models")
|
|
1715
|
+
.argument("[providerId]")
|
|
1716
|
+
.option("--json", "Output as JSON")
|
|
1717
|
+
.option("--enabled", "Only show enabled models")
|
|
1718
|
+
.option("--modality <modality>", "Filter by modality (e.g. text-to-text,image-to-text)")
|
|
1719
|
+
.option("--verbose", "Show full model metadata")
|
|
1720
|
+
.option("--no-check", "Skip health check for faster listing")
|
|
1721
|
+
.action(async (providerId, options) => {
|
|
1722
|
+
await listModelsAction(providerId, options);
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
models.addHelpText(
|
|
1726
|
+
"after",
|
|
1727
|
+
`
|
|
1728
|
+
Default: \`waypoi models [providerId]\` runs \`models list [providerId]\`.
|
|
1729
|
+
Examples:
|
|
1730
|
+
waypoi models
|
|
1731
|
+
waypoi models provider-id
|
|
1732
|
+
waypoi models show provider-id/model-id
|
|
1733
|
+
`
|
|
1734
|
+
);
|
|
1735
|
+
|
|
1736
|
+
models
|
|
1737
|
+
.command("list")
|
|
1738
|
+
.alias("ls")
|
|
1739
|
+
.description("List models, optionally filtered by provider")
|
|
1740
|
+
.argument("[providerId]")
|
|
1741
|
+
.option("--json", "Output as JSON")
|
|
1742
|
+
.option("--enabled", "Only show enabled models")
|
|
1743
|
+
.option("--modality <modality>", "Filter by modality (e.g. text-to-text,image-to-text)")
|
|
1744
|
+
.option("--verbose", "Show full model metadata")
|
|
1745
|
+
.option("--no-check", "Skip health check for faster listing")
|
|
1746
|
+
.action(async (providerId, options) => {
|
|
1747
|
+
await listModelsAction(providerId, options);
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
models
|
|
1751
|
+
.command("show")
|
|
1752
|
+
.description("Show one model (provider/model preferred)")
|
|
1753
|
+
.argument("<modelRef>")
|
|
1754
|
+
.action(async (modelRef) => {
|
|
1755
|
+
await ensureStorageDir(paths);
|
|
1756
|
+
const resolved = await resolveModelTarget(modelRef);
|
|
1757
|
+
if (!resolved) {
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
printJson({
|
|
1761
|
+
...resolved.model,
|
|
1762
|
+
providerId: resolved.providerId,
|
|
1763
|
+
modelId: resolved.modelId,
|
|
1764
|
+
});
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
models
|
|
1768
|
+
.command("enable")
|
|
1769
|
+
.description("Enable a provider model")
|
|
1770
|
+
.argument("<modelRef>")
|
|
1771
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
1772
|
+
.action(async (modelRef, options) => {
|
|
1773
|
+
await ensureStorageDir(paths);
|
|
1774
|
+
const resolved = await resolveModelTarget(modelRef);
|
|
1775
|
+
if (!resolved) {
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
const model = await setProviderModelEnabled(paths, resolved.providerId, resolved.modelId, true);
|
|
1779
|
+
if (!model) {
|
|
1780
|
+
printErrorWithSuggestion(`Model not found: ${modelRef}`, ["Try: waypoi models"]);
|
|
1781
|
+
process.exitCode = 1;
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
if (options.rebuild !== false) {
|
|
1785
|
+
await rebuildDefaultPools(paths);
|
|
1786
|
+
}
|
|
1787
|
+
console.log(`Enabled model: ${model.providerModelId}`);
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
models
|
|
1791
|
+
.command("disable")
|
|
1792
|
+
.description("Disable a provider model")
|
|
1793
|
+
.argument("<modelRef>")
|
|
1794
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
1795
|
+
.action(async (modelRef, options) => {
|
|
1796
|
+
await ensureStorageDir(paths);
|
|
1797
|
+
const resolved = await resolveModelTarget(modelRef);
|
|
1798
|
+
if (!resolved) {
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
const model = await setProviderModelEnabled(paths, resolved.providerId, resolved.modelId, false);
|
|
1802
|
+
if (!model) {
|
|
1803
|
+
printErrorWithSuggestion(`Model not found: ${modelRef}`, ["Try: waypoi models"]);
|
|
1804
|
+
process.exitCode = 1;
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
if (options.rebuild !== false) {
|
|
1808
|
+
await rebuildDefaultPools(paths);
|
|
1809
|
+
}
|
|
1810
|
+
console.log(`Disabled model: ${model.providerModelId}`);
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
models
|
|
1814
|
+
.command("set-key")
|
|
1815
|
+
.description("Set plaintext API key for a provider model")
|
|
1816
|
+
.argument("<modelRef>")
|
|
1817
|
+
.option("--api-key <key>", "API key value")
|
|
1818
|
+
.option("--env-var <name>", "Read API key from environment variable")
|
|
1819
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
1820
|
+
.action(async (modelRef, options) => {
|
|
1821
|
+
await ensureStorageDir(paths);
|
|
1822
|
+
const resolved = await resolveModelTarget(modelRef);
|
|
1823
|
+
if (!resolved) {
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
let apiKey: string | undefined = options.apiKey;
|
|
1827
|
+
if (!apiKey && options.envVar) {
|
|
1828
|
+
apiKey = process.env[String(options.envVar)] ?? undefined;
|
|
1829
|
+
if (!apiKey) {
|
|
1830
|
+
printErrorWithSuggestion(`Environment variable '${options.envVar}' is not set.`, [
|
|
1831
|
+
"Provide --api-key <key> or set the environment variable.",
|
|
1832
|
+
]);
|
|
1833
|
+
process.exitCode = 1;
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
if (!apiKey) {
|
|
1838
|
+
printErrorWithSuggestion("Provide --api-key or --env-var.", [
|
|
1839
|
+
"Try: waypoi models set-key provider/model --env-var API_KEY",
|
|
1840
|
+
]);
|
|
1841
|
+
process.exitCode = 1;
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
const model = await setProviderModelApiKey(paths, resolved.providerId, resolved.modelId, apiKey);
|
|
1845
|
+
if (!model) {
|
|
1846
|
+
printErrorWithSuggestion(`Model not found: ${modelRef}`, ["Try: waypoi models"]);
|
|
1847
|
+
process.exitCode = 1;
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
if (options.rebuild !== false) {
|
|
1851
|
+
await rebuildDefaultPools(paths);
|
|
1852
|
+
}
|
|
1853
|
+
console.log(`Updated key for model: ${model.providerModelId}`);
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
models
|
|
1857
|
+
.command("add")
|
|
1858
|
+
.description("Add a model under a provider")
|
|
1859
|
+
.argument("<providerId>")
|
|
1860
|
+
.requiredOption("--model-id <id>", "Provider model ID suffix")
|
|
1861
|
+
.requiredOption("--upstream <name>", "Upstream model name")
|
|
1862
|
+
.requiredOption("--base-url <url>", "Base URL for this model")
|
|
1863
|
+
.option("--api-key <key>", "API key for this model")
|
|
1864
|
+
.option("--insecure-tls", "Allow self-signed TLS certificates for this model")
|
|
1865
|
+
.option("--endpoint-type <type>", "Endpoint type (llm|diffusion|audio|embedding)", "llm")
|
|
1866
|
+
.option("--capability <spec...>", "Capability spec, e.g. text->text or text+image->text")
|
|
1867
|
+
.option("--alias <alias...>", "Legacy/public aliases")
|
|
1868
|
+
.option("--free", "Mark model as free")
|
|
1869
|
+
.option("--no-free", "Mark model as not free")
|
|
1870
|
+
.option("--disabled", "Add model in disabled state")
|
|
1871
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
1872
|
+
.action(async (providerId, options) => {
|
|
1873
|
+
await ensureStorageDir(paths);
|
|
1874
|
+
const providerRecord = await getProviderById(paths, providerId);
|
|
1875
|
+
if (!providerRecord) {
|
|
1876
|
+
printErrorWithSuggestion(`Provider not found: ${providerId}`, ["List providers: waypoi providers"]);
|
|
1877
|
+
process.exitCode = 1;
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
const endpointType = normalizeType(options.endpointType);
|
|
1881
|
+
const capabilities = options.capability
|
|
1882
|
+
? parseCapabilitySpecs(options.capability)
|
|
1883
|
+
: defaultCapabilitiesForEndpointType(endpointType);
|
|
1884
|
+
const modelId = String(options.modelId).trim();
|
|
1885
|
+
const providerModelId = canonicalProviderModelId(providerId, modelId);
|
|
1886
|
+
const modelRecord: ProviderModelRecord = {
|
|
1887
|
+
providerModelId,
|
|
1888
|
+
providerId,
|
|
1889
|
+
modelId,
|
|
1890
|
+
upstreamModel: String(options.upstream).trim(),
|
|
1891
|
+
baseUrl: String(options.baseUrl).trim(),
|
|
1892
|
+
apiKey: options.apiKey,
|
|
1893
|
+
insecureTls: options.insecureTls ? true : undefined,
|
|
1894
|
+
enabled: options.disabled ? false : true,
|
|
1895
|
+
aliases: normalizeAliasList(options.alias ?? []),
|
|
1896
|
+
free: options.free !== false,
|
|
1897
|
+
modalities: capabilitiesToModalities(capabilities),
|
|
1898
|
+
capabilities,
|
|
1899
|
+
endpointType,
|
|
1900
|
+
};
|
|
1901
|
+
const result = await upsertProviderModel(paths, providerId, modelRecord);
|
|
1902
|
+
if (!result) {
|
|
1903
|
+
console.error("Failed to add model");
|
|
1904
|
+
process.exitCode = 1;
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
if (options.rebuild !== false) {
|
|
1908
|
+
await rebuildDefaultPools(paths);
|
|
1909
|
+
}
|
|
1910
|
+
console.log(`Model ${result.created ? "added" : "updated"}: ${providerModelId}`);
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
models
|
|
1914
|
+
.command("update")
|
|
1915
|
+
.description("Update a provider model")
|
|
1916
|
+
.argument("<providerId>")
|
|
1917
|
+
.argument("<modelRef>")
|
|
1918
|
+
.option("--upstream <name>", "Set upstream model")
|
|
1919
|
+
.option("--base-url <url>", "Set base URL")
|
|
1920
|
+
.option("--clear-base-url", "Clear model-specific base URL override")
|
|
1921
|
+
.option("--api-key <key>", "Set API key")
|
|
1922
|
+
.option("--clear-api-key", "Clear model-specific API key")
|
|
1923
|
+
.option("--insecure-tls", "Enable insecure TLS for this model")
|
|
1924
|
+
.option("--clear-insecure-tls", "Clear model TLS override and inherit provider setting")
|
|
1925
|
+
.option("--endpoint-type <type>", "Endpoint type")
|
|
1926
|
+
.option("--capability <spec...>", "Replace capabilities")
|
|
1927
|
+
.option("--alias <alias...>", "Set aliases")
|
|
1928
|
+
.option("--free", "Set free=true")
|
|
1929
|
+
.option("--not-free", "Set free=false")
|
|
1930
|
+
.option("--enabled", "Set enabled=true")
|
|
1931
|
+
.option("--disabled", "Set enabled=false")
|
|
1932
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
1933
|
+
.action(async (providerId, modelRef, options) => {
|
|
1934
|
+
await ensureStorageDir(paths);
|
|
1935
|
+
const patch: Partial<ProviderModelRecord> = {};
|
|
1936
|
+
if (typeof options.upstream === "string") {
|
|
1937
|
+
patch.upstreamModel = options.upstream.trim();
|
|
1938
|
+
}
|
|
1939
|
+
if (typeof options.baseUrl === "string") {
|
|
1940
|
+
patch.baseUrl = options.baseUrl.trim();
|
|
1941
|
+
}
|
|
1942
|
+
if (options.clearBaseUrl) {
|
|
1943
|
+
patch.baseUrl = undefined;
|
|
1944
|
+
}
|
|
1945
|
+
if (typeof options.apiKey === "string") {
|
|
1946
|
+
patch.apiKey = options.apiKey;
|
|
1947
|
+
}
|
|
1948
|
+
if (options.clearApiKey) {
|
|
1949
|
+
patch.apiKey = undefined;
|
|
1950
|
+
}
|
|
1951
|
+
if (options.insecureTls) {
|
|
1952
|
+
patch.insecureTls = true;
|
|
1953
|
+
}
|
|
1954
|
+
if (options.clearInsecureTls) {
|
|
1955
|
+
patch.insecureTls = undefined;
|
|
1956
|
+
}
|
|
1957
|
+
if (typeof options.endpointType === "string") {
|
|
1958
|
+
patch.endpointType = normalizeType(options.endpointType);
|
|
1959
|
+
}
|
|
1960
|
+
if (options.capability) {
|
|
1961
|
+
const capabilities = parseCapabilitySpecs(options.capability);
|
|
1962
|
+
patch.capabilities = capabilities;
|
|
1963
|
+
patch.modalities = capabilitiesToModalities(capabilities);
|
|
1964
|
+
}
|
|
1965
|
+
if (options.alias) {
|
|
1966
|
+
patch.aliases = normalizeAliasList(options.alias);
|
|
1967
|
+
}
|
|
1968
|
+
if (options.free) {
|
|
1969
|
+
patch.free = true;
|
|
1970
|
+
}
|
|
1971
|
+
if (options.notFree) {
|
|
1972
|
+
patch.free = false;
|
|
1973
|
+
}
|
|
1974
|
+
if (options.enabled) {
|
|
1975
|
+
patch.enabled = true;
|
|
1976
|
+
}
|
|
1977
|
+
if (options.disabled) {
|
|
1978
|
+
patch.enabled = false;
|
|
1979
|
+
}
|
|
1980
|
+
if (Object.keys(patch).length === 0) {
|
|
1981
|
+
printErrorWithSuggestion("No changes requested.", [
|
|
1982
|
+
"Try: waypoi models update <providerId> <modelRef> --upstream <name>",
|
|
1983
|
+
]);
|
|
1984
|
+
process.exitCode = 1;
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
const updated = await updateProviderModel(paths, providerId, modelRef, patch);
|
|
1988
|
+
if (!updated) {
|
|
1989
|
+
printErrorWithSuggestion(`Model not found: ${providerId}/${modelRef}`, [
|
|
1990
|
+
`Try: waypoi models ${providerId}`,
|
|
1991
|
+
]);
|
|
1992
|
+
process.exitCode = 1;
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
if (options.rebuild !== false) {
|
|
1996
|
+
await rebuildDefaultPools(paths);
|
|
1997
|
+
}
|
|
1998
|
+
console.log(`Updated model: ${updated.providerModelId}`);
|
|
1999
|
+
});
|
|
2000
|
+
|
|
2001
|
+
models
|
|
2002
|
+
.command("rm")
|
|
2003
|
+
.description("Remove a provider model")
|
|
2004
|
+
.argument("<providerId>")
|
|
2005
|
+
.argument("<modelRef>")
|
|
2006
|
+
.option("--no-rebuild", "Skip automatic pool rebuild")
|
|
2007
|
+
.action(async (providerId, modelRef, options) => {
|
|
2008
|
+
await ensureStorageDir(paths);
|
|
2009
|
+
const removed = await deleteProviderModel(paths, providerId, modelRef);
|
|
2010
|
+
if (!removed) {
|
|
2011
|
+
printErrorWithSuggestion(`Model not found: ${providerId}/${modelRef}`, [
|
|
2012
|
+
`Try: waypoi models ${providerId}`,
|
|
2013
|
+
]);
|
|
2014
|
+
process.exitCode = 1;
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
if (options.rebuild !== false) {
|
|
2018
|
+
await rebuildDefaultPools(paths);
|
|
2019
|
+
}
|
|
2020
|
+
console.log(`Removed model: ${removed.providerModelId}`);
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2024
|
+
// Benchmark Command
|
|
2025
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2026
|
+
|
|
2027
|
+
program
|
|
2028
|
+
.command("bench")
|
|
2029
|
+
.alias("benchmark")
|
|
2030
|
+
.description("Run showcase benchmark examples or internal diagnostic suites")
|
|
2031
|
+
.option("--suite <name>", "Built-in suite to run (default: showcase)")
|
|
2032
|
+
.option("--example <id>", "Run one built-in example from the selected suite")
|
|
2033
|
+
.option("--list-examples", "List showcase examples and exit")
|
|
2034
|
+
.option("--scenario <path>", "Scenario file (.json, .jsonl, .yaml)")
|
|
2035
|
+
.option("--model <name>", "Override model for all scenarios")
|
|
2036
|
+
.option("--out <path>", "Output file path or directory for benchmark artifact")
|
|
2037
|
+
.option("--config <path>", "Benchmark config file (YAML or JSON)")
|
|
2038
|
+
.option("--profile <name>", "Benchmark profile (local|ci)")
|
|
2039
|
+
.option("--mode <name>", "Execution mode (showcase|diagnostic)")
|
|
2040
|
+
.option("--baseline <path>", "Baseline benchmark JSON for regression comparison")
|
|
2041
|
+
.option("--update-cap-cache", "Persist capability findings to capability cache")
|
|
2042
|
+
.option("--cap-ttl-days <n>", "Capability cache TTL days for freshness/output", parseInt)
|
|
2043
|
+
.option("--temperature <n>", "Run-level temperature override", parseFloat)
|
|
2044
|
+
.option("--top-p <n>", "Run-level top_p override", parseFloat)
|
|
2045
|
+
.option("--max-tokens <n>", "Run-level max_tokens override", parseInt)
|
|
2046
|
+
.option("--presence-penalty <n>", "Run-level presence penalty override", parseFloat)
|
|
2047
|
+
.option("--frequency-penalty <n>", "Run-level frequency penalty override", parseFloat)
|
|
2048
|
+
.option("--seed <n>", "Run-level seed override", parseInt)
|
|
2049
|
+
.option("--stop <value>", "Run-level stop sequence override (comma-separated for multiple)")
|
|
2050
|
+
.action(async (options) => {
|
|
2051
|
+
await ensureStorageDir(paths);
|
|
2052
|
+
try {
|
|
2053
|
+
if (options.listExamples) {
|
|
2054
|
+
const suiteName = options.suite ?? "showcase";
|
|
2055
|
+
const examples = listBenchmarkExamples(suiteName);
|
|
2056
|
+
console.log(`\nExamples in suite '${suiteName}':\n`);
|
|
2057
|
+
console.table(
|
|
2058
|
+
examples.map((example) => ({
|
|
2059
|
+
id: example.id,
|
|
2060
|
+
mode: example.mode,
|
|
2061
|
+
title: example.title,
|
|
2062
|
+
source: example.exampleSource,
|
|
2063
|
+
tools: example.requiresAvailableTools ? "required" : "optional",
|
|
2064
|
+
}))
|
|
2065
|
+
);
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
const { report, artifactPath, textArtifactPath } = await runBenchmark(paths, {
|
|
2070
|
+
temperature: options.temperature,
|
|
2071
|
+
top_p: options.topP,
|
|
2072
|
+
max_tokens: options.maxTokens,
|
|
2073
|
+
presence_penalty: options.presencePenalty,
|
|
2074
|
+
frequency_penalty: options.frequencyPenalty,
|
|
2075
|
+
seed: options.seed,
|
|
2076
|
+
stop: typeof options.stop === "string"
|
|
2077
|
+
? options.stop.split(",").map((item: string) => item.trim()).filter(Boolean)
|
|
2078
|
+
: undefined,
|
|
2079
|
+
suite: options.suite,
|
|
2080
|
+
exampleId: options.example,
|
|
2081
|
+
scenarioPath: options.scenario,
|
|
2082
|
+
modelOverride: options.model,
|
|
2083
|
+
outPath: options.out,
|
|
2084
|
+
configPath: options.config,
|
|
2085
|
+
profile: options.profile,
|
|
2086
|
+
baselinePath: options.baseline,
|
|
2087
|
+
executionMode: options.mode,
|
|
2088
|
+
updateCapCache: options.updateCapCache,
|
|
2089
|
+
capTtlDays: options.capTtlDays,
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
console.log("\n🏁 Benchmark complete");
|
|
2093
|
+
console.log(` Profile: ${report.profile}`);
|
|
2094
|
+
console.log(` Mode: ${report.executionMode}`);
|
|
2095
|
+
if (report.suite) {
|
|
2096
|
+
console.log(` Suite: ${report.suite}`);
|
|
2097
|
+
}
|
|
2098
|
+
if (report.exampleId) {
|
|
2099
|
+
console.log(` Example: ${report.exampleId}`);
|
|
2100
|
+
}
|
|
2101
|
+
if (report.capabilityMatrix) {
|
|
2102
|
+
console.log(` Cap TTL: ${report.capabilityMatrix.ttlDays}d`);
|
|
2103
|
+
}
|
|
2104
|
+
console.log(` Scenarios: ${report.total}`);
|
|
2105
|
+
console.log(` Executed: ${report.executed}`);
|
|
2106
|
+
console.log(` Skipped: ${report.skipped}`);
|
|
2107
|
+
console.log(` Success: ${report.succeeded}`);
|
|
2108
|
+
console.log(` Failed: ${report.failed}`);
|
|
2109
|
+
console.log(` SuccessRate: ${(report.successRate * 100).toFixed(1)}%`);
|
|
2110
|
+
console.log(` AvgLatency: ${report.avgLatencyMs}ms`);
|
|
2111
|
+
console.log(` P95Latency: ${report.p95LatencyMs}ms`);
|
|
2112
|
+
console.log(` Tokens: ${report.totalTokens}`);
|
|
2113
|
+
console.log(` ToolCalls: ${report.totalToolCalls}`);
|
|
2114
|
+
console.log(` Throughput: ${report.avgThroughputTokensPerSec.toFixed(2)} t/s`);
|
|
2115
|
+
console.log(` Artifact: ${artifactPath}\n`);
|
|
2116
|
+
console.log(` Summary: ${textArtifactPath}\n`);
|
|
2117
|
+
|
|
2118
|
+
if (report.executionMode === "showcase" && report.scenarioDetails.length > 0) {
|
|
2119
|
+
console.log("Showcase details:");
|
|
2120
|
+
for (const detail of report.scenarioDetails) {
|
|
2121
|
+
console.log(`- ${detail.example?.title ?? detail.id}`);
|
|
2122
|
+
console.log(` Goal: ${detail.example?.userVisibleGoal ?? "n/a"}`);
|
|
2123
|
+
console.log(` Model: ${detail.model}`);
|
|
2124
|
+
console.log(` Verdict: ${detail.verdict}`);
|
|
2125
|
+
if (detail.usedToolNames.length > 0) {
|
|
2126
|
+
console.log(` Tools: ${detail.usedToolNames.join(", ")}`);
|
|
2127
|
+
}
|
|
2128
|
+
if (detail.finalResponsePreview) {
|
|
2129
|
+
console.log(` Final: ${detail.finalResponsePreview}`);
|
|
2130
|
+
}
|
|
2131
|
+
if (detail.exchanges.length > 0) {
|
|
2132
|
+
const finalExchange = detail.exchanges[detail.exchanges.length - 1];
|
|
2133
|
+
console.log(` Request: ${finalExchange.requestPath}`);
|
|
2134
|
+
console.log(` Response: ${finalExchange.responsePreview}`);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
console.log();
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
if (report.warnings.length > 0) {
|
|
2141
|
+
console.log("Warnings:");
|
|
2142
|
+
for (const warning of report.warnings) {
|
|
2143
|
+
console.log(` - ${warning}`);
|
|
2144
|
+
}
|
|
2145
|
+
console.log();
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
const skipped = report.results.filter((item) => item.status === "skipped");
|
|
2149
|
+
if (skipped.length > 0) {
|
|
2150
|
+
console.log("Skipped scenarios:");
|
|
2151
|
+
console.table(
|
|
2152
|
+
skipped.map((item) => ({
|
|
2153
|
+
id: item.id,
|
|
2154
|
+
mode: item.mode,
|
|
2155
|
+
reason: item.skippedReason ?? "no compatible model",
|
|
2156
|
+
}))
|
|
2157
|
+
);
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
if (report.gateResults.soft.messages.length > 0) {
|
|
2161
|
+
console.log("Soft gate warnings:");
|
|
2162
|
+
for (const warning of report.gateResults.soft.messages) {
|
|
2163
|
+
console.log(` - ${warning}`);
|
|
2164
|
+
}
|
|
2165
|
+
console.log();
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
if (!report.gateResults.hard.passed) {
|
|
2169
|
+
console.log("Hard gate failures:");
|
|
2170
|
+
for (const failure of report.gateResults.hard.messages) {
|
|
2171
|
+
console.log(` - ${failure}`);
|
|
2172
|
+
}
|
|
2173
|
+
console.log();
|
|
2174
|
+
|
|
2175
|
+
const failed = report.results.filter((item) => !item.success);
|
|
2176
|
+
if (failed.length > 0) {
|
|
2177
|
+
console.log("Failed scenarios:");
|
|
2178
|
+
console.table(
|
|
2179
|
+
failed.map((item) => ({
|
|
2180
|
+
id: item.id,
|
|
2181
|
+
mode: item.mode,
|
|
2182
|
+
model: item.model,
|
|
2183
|
+
passRate: `${(item.passRate * 100).toFixed(1)}%`,
|
|
2184
|
+
error: item.errorReasons[0] ?? "failed",
|
|
2185
|
+
}))
|
|
2186
|
+
);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
process.exitCode = 1;
|
|
2190
|
+
} else {
|
|
2191
|
+
const failed = report.results.filter((item) => !item.success);
|
|
2192
|
+
if (failed.length > 0) {
|
|
2193
|
+
console.log("Scenarios below pass-rate threshold:");
|
|
2194
|
+
console.table(
|
|
2195
|
+
failed.map((item) => ({
|
|
2196
|
+
id: item.id,
|
|
2197
|
+
mode: item.mode,
|
|
2198
|
+
model: item.model,
|
|
2199
|
+
passRate: `${(item.passRate * 100).toFixed(1)}%`,
|
|
2200
|
+
error: item.errorReasons[0] ?? "failed",
|
|
2201
|
+
}))
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
if (report.gateResults.soft.messages.length > 0 && report.gateResults.hard.passed) {
|
|
2207
|
+
console.log("Benchmark finished with soft warnings (exit code 0).");
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
if (report.capabilityMatrix && report.capabilityMatrix.models.length > 0) {
|
|
2211
|
+
console.log("\nCapability Matrix:");
|
|
2212
|
+
const rows = report.capabilityMatrix.models.map((model) => ({
|
|
2213
|
+
model: model.model,
|
|
2214
|
+
freshness: model.freshness,
|
|
2215
|
+
verified: model.lastVerifiedAt,
|
|
2216
|
+
chat: model.findings.chat_basic.status,
|
|
2217
|
+
tools: model.findings.chat_tool_calls.status,
|
|
2218
|
+
embed: model.findings.embeddings.status,
|
|
2219
|
+
image: model.findings.images_generation.status,
|
|
2220
|
+
audioIn: model.findings.audio_transcription.status,
|
|
2221
|
+
audioOut: model.findings.audio_speech.status,
|
|
2222
|
+
}));
|
|
2223
|
+
console.table(rows);
|
|
2224
|
+
}
|
|
2225
|
+
} catch (error) {
|
|
2226
|
+
console.error(`Benchmark failed: ${(error as Error).message}`);
|
|
2227
|
+
process.exitCode = 1;
|
|
2228
|
+
}
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2232
|
+
// Chat Command (requires the waypoi server to be running)
|
|
2233
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2234
|
+
|
|
2235
|
+
program
|
|
2236
|
+
.command("chat")
|
|
2237
|
+
.description("Send a message and stream the response (server must be running)")
|
|
2238
|
+
.argument("[message]", "Message to send (reads from stdin if omitted)")
|
|
2239
|
+
.option("--model <model>", "Model to use")
|
|
2240
|
+
.option("--session <id>", "Continue an existing session")
|
|
2241
|
+
.option("--no-stream", "Return full response instead of streaming")
|
|
2242
|
+
.option("--port <port>", "Waypoi server port", DEFAULT_PORT)
|
|
2243
|
+
.option("--json", "Output raw JSON response (implies --no-stream)")
|
|
2244
|
+
.action(async (message: string | undefined, options: {
|
|
2245
|
+
model?: string;
|
|
2246
|
+
session?: string;
|
|
2247
|
+
stream: boolean;
|
|
2248
|
+
port: string;
|
|
2249
|
+
json?: boolean;
|
|
2250
|
+
}) => {
|
|
2251
|
+
const baseUrl = `http://localhost:${options.port}`;
|
|
2252
|
+
let content = message;
|
|
2253
|
+
|
|
2254
|
+
// Read from stdin if no argument given and stdin is piped
|
|
2255
|
+
if (!content && !process.stdin.isTTY) {
|
|
2256
|
+
const chunks: Buffer[] = [];
|
|
2257
|
+
for await (const chunk of process.stdin) {
|
|
2258
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
2259
|
+
}
|
|
2260
|
+
content = Buffer.concat(chunks).toString("utf8").trim();
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
if (!content) {
|
|
2264
|
+
console.error("Provide a message as argument or pipe it via stdin.");
|
|
2265
|
+
process.exitCode = 1;
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
// Resolve or create session
|
|
2270
|
+
let sessionId = options.session;
|
|
2271
|
+
if (!sessionId) {
|
|
2272
|
+
try {
|
|
2273
|
+
const resp = await request(`${baseUrl}/admin/sessions`, {
|
|
2274
|
+
method: "POST",
|
|
2275
|
+
headers: { "content-type": "application/json" },
|
|
2276
|
+
body: JSON.stringify({ model: options.model }),
|
|
2277
|
+
});
|
|
2278
|
+
const body = await resp.body.json() as { id?: string };
|
|
2279
|
+
sessionId = body.id;
|
|
2280
|
+
} catch (err) {
|
|
2281
|
+
console.error(`Cannot reach server at ${baseUrl} — is it running? (${appConfig.appName} service start)`);
|
|
2282
|
+
console.error((err as Error).message);
|
|
2283
|
+
process.exitCode = 1;
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
const payload: Record<string, unknown> = {
|
|
2289
|
+
model: options.model ?? "smart",
|
|
2290
|
+
messages: [{ role: "user", content }],
|
|
2291
|
+
stream: !options.json && options.stream !== false,
|
|
2292
|
+
};
|
|
2293
|
+
|
|
2294
|
+
const useStream = !options.json && options.stream !== false;
|
|
2295
|
+
|
|
2296
|
+
try {
|
|
2297
|
+
const resp = await request(`${baseUrl}/v1/chat/completions`, {
|
|
2298
|
+
method: "POST",
|
|
2299
|
+
headers: { "content-type": "application/json" },
|
|
2300
|
+
body: JSON.stringify(payload),
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
if (!useStream || options.json) {
|
|
2304
|
+
const body = await resp.body.json() as { choices?: Array<{ message?: { content?: string } }> };
|
|
2305
|
+
if (options.json) {
|
|
2306
|
+
printJson(body);
|
|
2307
|
+
} else {
|
|
2308
|
+
const text = body.choices?.[0]?.message?.content ?? "";
|
|
2309
|
+
process.stdout.write(text + "\n");
|
|
2310
|
+
}
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// Streaming SSE
|
|
2315
|
+
let fullContent = "";
|
|
2316
|
+
const decoder = new TextDecoder();
|
|
2317
|
+
for await (const chunk of resp.body) {
|
|
2318
|
+
const text = decoder.decode(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
2319
|
+
for (const line of text.split("\n")) {
|
|
2320
|
+
if (!line.startsWith("data: ")) continue;
|
|
2321
|
+
const data = line.slice(6).trim();
|
|
2322
|
+
if (data === "[DONE]") break;
|
|
2323
|
+
try {
|
|
2324
|
+
const parsed = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string } }> };
|
|
2325
|
+
const delta = parsed.choices?.[0]?.delta?.content;
|
|
2326
|
+
if (delta) {
|
|
2327
|
+
process.stdout.write(delta);
|
|
2328
|
+
fullContent += delta;
|
|
2329
|
+
}
|
|
2330
|
+
} catch {
|
|
2331
|
+
// Skip malformed SSE chunk
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
process.stdout.write("\n");
|
|
2336
|
+
|
|
2337
|
+
// Save to session
|
|
2338
|
+
if (sessionId) {
|
|
2339
|
+
const model = options.model ?? "smart";
|
|
2340
|
+
await request(`${baseUrl}/admin/sessions/${sessionId}/messages`, {
|
|
2341
|
+
method: "POST",
|
|
2342
|
+
headers: { "content-type": "application/json" },
|
|
2343
|
+
body: JSON.stringify({ role: "user", content }),
|
|
2344
|
+
});
|
|
2345
|
+
await request(`${baseUrl}/admin/sessions/${sessionId}/messages`, {
|
|
2346
|
+
method: "POST",
|
|
2347
|
+
headers: { "content-type": "application/json" },
|
|
2348
|
+
body: JSON.stringify({ role: "assistant", content: fullContent, model }),
|
|
2349
|
+
});
|
|
2350
|
+
console.error(`\n[session: ${sessionId}]`);
|
|
2351
|
+
}
|
|
2352
|
+
} catch (err) {
|
|
2353
|
+
console.error(`Chat request failed: ${(err as Error).message}`);
|
|
2354
|
+
process.exitCode = 1;
|
|
2355
|
+
}
|
|
2356
|
+
});
|
|
2357
|
+
|
|
2358
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2359
|
+
// Sessions Command
|
|
2360
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2361
|
+
|
|
2362
|
+
const sessions = program
|
|
2363
|
+
.command("sessions")
|
|
2364
|
+
.alias("session")
|
|
2365
|
+
.description("Manage chat sessions")
|
|
2366
|
+
.option("--port <port>", "Waypoi server port", DEFAULT_PORT)
|
|
2367
|
+
.option("--json", "Output as JSON")
|
|
2368
|
+
.action(async (options: { port: string; json?: boolean }) => {
|
|
2369
|
+
await listSessionsAction(options);
|
|
2370
|
+
});
|
|
2371
|
+
|
|
2372
|
+
async function listSessionsAction(options: { port: string; json?: boolean }): Promise<void> {
|
|
2373
|
+
const baseUrl = `http://localhost:${options.port}`;
|
|
2374
|
+
try {
|
|
2375
|
+
const resp = await request(`${baseUrl}/admin/sessions`, { method: "GET" });
|
|
2376
|
+
const body = await resp.body.json() as Array<{
|
|
2377
|
+
id: string;
|
|
2378
|
+
name: string;
|
|
2379
|
+
model?: string;
|
|
2380
|
+
messageCount?: number;
|
|
2381
|
+
updatedAt?: string;
|
|
2382
|
+
}>;
|
|
2383
|
+
if (options.json) {
|
|
2384
|
+
printJson(body);
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2387
|
+
if (!body.length) {
|
|
2388
|
+
console.log("No sessions found.");
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
console.table(body.map((s) => ({
|
|
2392
|
+
id: s.id.slice(0, 8),
|
|
2393
|
+
name: s.name,
|
|
2394
|
+
model: s.model ?? "-",
|
|
2395
|
+
messages: s.messageCount ?? "-",
|
|
2396
|
+
updated: s.updatedAt ? new Date(s.updatedAt).toLocaleString() : "-",
|
|
2397
|
+
})));
|
|
2398
|
+
} catch (err) {
|
|
2399
|
+
console.error(`Cannot reach server — is it running? (${appConfig.appName} service start)\n${(err as Error).message}`);
|
|
2400
|
+
process.exitCode = 1;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
sessions
|
|
2405
|
+
.command("list")
|
|
2406
|
+
.alias("ls")
|
|
2407
|
+
.description("List sessions")
|
|
2408
|
+
.option("--port <port>", "Waypoi server port", DEFAULT_PORT)
|
|
2409
|
+
.option("--json", "Output as JSON")
|
|
2410
|
+
.action(async (options: { port: string; json?: boolean }) => {
|
|
2411
|
+
await listSessionsAction(options);
|
|
2412
|
+
});
|
|
2413
|
+
|
|
2414
|
+
sessions
|
|
2415
|
+
.command("show <id>")
|
|
2416
|
+
.description("Print full message history of a session")
|
|
2417
|
+
.option("--port <port>", "Waypoi server port", DEFAULT_PORT)
|
|
2418
|
+
.option("--json", "Output as JSON")
|
|
2419
|
+
.action(async (id: string, options: { port: string; json?: boolean }) => {
|
|
2420
|
+
const baseUrl = `http://localhost:${options.port}`;
|
|
2421
|
+
try {
|
|
2422
|
+
const resp = await request(`${baseUrl}/admin/sessions/${id}`, { method: "GET" });
|
|
2423
|
+
const body = await resp.body.json() as { messages?: Array<{ role: string; content?: unknown; model?: string; createdAt?: string }> };
|
|
2424
|
+
if (options.json) {
|
|
2425
|
+
printJson(body);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
for (const msg of body.messages ?? []) {
|
|
2429
|
+
const ts = msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString() : "";
|
|
2430
|
+
const label = msg.model ? `${msg.role} (${msg.model})` : msg.role;
|
|
2431
|
+
console.log(`\n[${ts}] ${label}:`);
|
|
2432
|
+
if (typeof msg.content === "string") {
|
|
2433
|
+
console.log(msg.content);
|
|
2434
|
+
} else {
|
|
2435
|
+
console.log(JSON.stringify(msg.content, null, 2));
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
} catch (err) {
|
|
2439
|
+
console.error(`${(err as Error).message}`);
|
|
2440
|
+
process.exitCode = 1;
|
|
2441
|
+
}
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
sessions
|
|
2445
|
+
.command("rm <id>")
|
|
2446
|
+
.alias("delete")
|
|
2447
|
+
.description("Delete a session")
|
|
2448
|
+
.option("--port <port>", "Waypoi server port", DEFAULT_PORT)
|
|
2449
|
+
.action(async (id: string, options: { port: string }) => {
|
|
2450
|
+
const baseUrl = `http://localhost:${options.port}`;
|
|
2451
|
+
try {
|
|
2452
|
+
await request(`${baseUrl}/admin/sessions/${id}`, { method: "DELETE" });
|
|
2453
|
+
console.log(`Deleted session: ${id}`);
|
|
2454
|
+
} catch (err) {
|
|
2455
|
+
console.error(`${(err as Error).message}`);
|
|
2456
|
+
process.exitCode = 1;
|
|
2457
|
+
}
|
|
2458
|
+
});
|
|
2459
|
+
|
|
2460
|
+
sessions
|
|
2461
|
+
.command("export <id>")
|
|
2462
|
+
.description("Export session messages as JSONL to stdout")
|
|
2463
|
+
.option("--port <port>", "Waypoi server port", DEFAULT_PORT)
|
|
2464
|
+
.action(async (id: string, options: { port: string }) => {
|
|
2465
|
+
const baseUrl = `http://localhost:${options.port}`;
|
|
2466
|
+
try {
|
|
2467
|
+
const resp = await request(`${baseUrl}/admin/sessions/${id}`, { method: "GET" });
|
|
2468
|
+
const body = await resp.body.json() as { messages?: unknown[] };
|
|
2469
|
+
for (const msg of body.messages ?? []) {
|
|
2470
|
+
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
2471
|
+
}
|
|
2472
|
+
} catch (err) {
|
|
2473
|
+
console.error(`${(err as Error).message}`);
|
|
2474
|
+
process.exitCode = 1;
|
|
2475
|
+
}
|
|
2476
|
+
});
|
|
2477
|
+
|
|
2478
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2479
|
+
const rawArgv = process.argv.slice(2);
|
|
2480
|
+
const rewriteResult = rewriteLegacyArgv(rawArgv);
|
|
2481
|
+
warnLegacyRewrite(rewriteResult);
|
|
2482
|
+
|
|
2483
|
+
program.parseAsync(["node", "waypoi", ...rewriteResult.argv]).catch((error) => {
|
|
2484
|
+
console.error(error);
|
|
2485
|
+
process.exit(1);
|
|
2486
|
+
});
|
|
2487
|
+
|
|
2488
|
+
function compactEndpointUrl(value: string, maxLength = 36): string {
|
|
2489
|
+
try {
|
|
2490
|
+
const parsed = new URL(value);
|
|
2491
|
+
return truncateText(`${parsed.protocol}//${parsed.host}`, maxLength);
|
|
2492
|
+
} catch {
|
|
2493
|
+
return truncateText(value, maxLength);
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
function truncateText(value: string, maxLength: number): string {
|
|
2498
|
+
if (value.length <= maxLength) {
|
|
2499
|
+
return value;
|
|
2500
|
+
}
|
|
2501
|
+
if (maxLength <= 1) {
|
|
2502
|
+
return value.slice(0, maxLength);
|
|
2503
|
+
}
|
|
2504
|
+
return `${value.slice(0, maxLength - 1)}…`;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
function normalizeProviderProtocol(value: string): ProviderProtocol {
|
|
2508
|
+
const normalized = canonicalizeProtocol(value);
|
|
2509
|
+
if (normalized === "openai" || normalized === "inference_v2") {
|
|
2510
|
+
return normalized;
|
|
2511
|
+
}
|
|
2512
|
+
return "unknown";
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
function hostMatchesDomain(hostname: string, domain: string): boolean {
|
|
2516
|
+
const normalizedHost = hostname.toLowerCase();
|
|
2517
|
+
const normalizedDomain = domain.replace(/^\*\./, "").toLowerCase();
|
|
2518
|
+
return normalizedHost === normalizedDomain || normalizedHost.endsWith(`.${normalizedDomain}`);
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
function capabilitiesToModalities(capabilities: ModelCapabilities): string[] {
|
|
2522
|
+
const modalities = new Set<string>();
|
|
2523
|
+
const hasTextInput = capabilities.input.includes("text");
|
|
2524
|
+
const hasImageInput = capabilities.input.includes("image");
|
|
2525
|
+
const hasAudioInput = capabilities.input.includes("audio");
|
|
2526
|
+
const hasTextOutput = capabilities.output.includes("text");
|
|
2527
|
+
const hasImageOutput = capabilities.output.includes("image");
|
|
2528
|
+
const hasAudioOutput = capabilities.output.includes("audio");
|
|
2529
|
+
const hasEmbeddingOutput = capabilities.output.includes("embedding");
|
|
2530
|
+
|
|
2531
|
+
if (hasTextInput && hasTextOutput) {
|
|
2532
|
+
modalities.add("text-to-text");
|
|
2533
|
+
}
|
|
2534
|
+
if (hasImageInput && hasTextOutput) {
|
|
2535
|
+
modalities.add("image-to-text");
|
|
2536
|
+
}
|
|
2537
|
+
if (hasTextInput && hasImageOutput) {
|
|
2538
|
+
modalities.add("text-to-image");
|
|
2539
|
+
}
|
|
2540
|
+
if (hasAudioInput && hasTextOutput) {
|
|
2541
|
+
modalities.add("audio-to-text");
|
|
2542
|
+
}
|
|
2543
|
+
if (hasTextInput && hasAudioOutput) {
|
|
2544
|
+
modalities.add("text-to-audio");
|
|
2545
|
+
}
|
|
2546
|
+
if (hasTextInput && hasEmbeddingOutput) {
|
|
2547
|
+
modalities.add("text-to-embedding");
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
return Array.from(modalities);
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
function defaultCapabilitiesForEndpointType(
|
|
2554
|
+
endpointType: "llm" | "diffusion" | "audio" | "embedding" | "video"
|
|
2555
|
+
): ModelCapabilities {
|
|
2556
|
+
if (endpointType === "embedding") {
|
|
2557
|
+
return { input: ["text"], output: ["embedding"], source: "configured" };
|
|
2558
|
+
}
|
|
2559
|
+
if (endpointType === "diffusion") {
|
|
2560
|
+
return { input: ["text"], output: ["image"], source: "configured" };
|
|
2561
|
+
}
|
|
2562
|
+
if (endpointType === "audio") {
|
|
2563
|
+
return { input: ["audio"], output: ["text"], source: "configured" };
|
|
2564
|
+
}
|
|
2565
|
+
if (endpointType === "video") {
|
|
2566
|
+
return { input: ["text"], output: ["video"], source: "configured" };
|
|
2567
|
+
}
|
|
2568
|
+
return {
|
|
2569
|
+
input: ["text"],
|
|
2570
|
+
output: ["text"],
|
|
2571
|
+
supportsTools: true,
|
|
2572
|
+
supportsStreaming: true,
|
|
2573
|
+
source: "configured",
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
function parseCapabilitySpecs(values: string[]): ModelCapabilities {
|
|
2578
|
+
const input = new Set<ModelModality>();
|
|
2579
|
+
const output = new Set<ModelModality>();
|
|
2580
|
+
for (const value of values) {
|
|
2581
|
+
const [inputSpec, outputSpec] = value.split("->").map((part) => part.trim());
|
|
2582
|
+
if (!inputSpec || !outputSpec) {
|
|
2583
|
+
throw new Error(`Invalid capability spec '${value}'. Use format input->output, e.g. text+image->text`);
|
|
2584
|
+
}
|
|
2585
|
+
for (const modality of inputSpec.split("+").map((item) => item.trim())) {
|
|
2586
|
+
input.add(parseModality(modality));
|
|
2587
|
+
}
|
|
2588
|
+
for (const modality of outputSpec.split("+").map((item) => item.trim())) {
|
|
2589
|
+
output.add(parseModality(modality));
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
if (input.size === 0 || output.size === 0) {
|
|
2593
|
+
throw new Error("Capability spec must include at least one input and one output modality.");
|
|
2594
|
+
}
|
|
2595
|
+
return {
|
|
2596
|
+
input: Array.from(input),
|
|
2597
|
+
output: Array.from(output),
|
|
2598
|
+
source: "configured",
|
|
2599
|
+
};
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
function parseModality(value: string): ModelModality {
|
|
2603
|
+
if (value === "text" || value === "image" || value === "audio" || value === "embedding" || value === "video") {
|
|
2604
|
+
return value;
|
|
2605
|
+
}
|
|
2606
|
+
throw new Error(`Unsupported modality '${value}'. Use one of: text,image,audio,embedding,video.`);
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
function normalizeAliasList(values: string[]): string[] {
|
|
2610
|
+
const seen = new Set<string>();
|
|
2611
|
+
for (const value of values) {
|
|
2612
|
+
const alias = value.trim();
|
|
2613
|
+
if (alias.length > 0) {
|
|
2614
|
+
seen.add(alias);
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
return Array.from(seen);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
function writeMigrationReport(baseDir: string, payload: Record<string, unknown>): string {
|
|
2621
|
+
const migrationsDir = path.join(baseDir, "migrations");
|
|
2622
|
+
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
2623
|
+
const filePath = path.join(
|
|
2624
|
+
migrationsDir,
|
|
2625
|
+
`migrate-${new Date().toISOString().replace(/[:.]/g, "-")}.json`
|
|
2626
|
+
);
|
|
2627
|
+
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf8");
|
|
2628
|
+
return filePath;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
function isImageModel(model: string): boolean {
|
|
2632
|
+
const name = model.toLowerCase();
|
|
2633
|
+
return name.includes("diffusion") || name.includes("stable") || name.includes("sd") || name.includes("flux");
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
function isAudioModel(model: string): boolean {
|
|
2637
|
+
const name = model.toLowerCase();
|
|
2638
|
+
return name.includes("whisper") || name.includes("tts") || name.includes("speech");
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
function isVideoModel(model: string): boolean {
|
|
2642
|
+
const name = model.toLowerCase();
|
|
2643
|
+
return name.includes("wan") || name.includes("video") || name.includes("i2v") || name.includes("t2v");
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
async function resolveModelType(model: string): Promise<"llm" | "diffusion" | "audio" | "embedding" | "video"> {
|
|
2647
|
+
const providerModels = await listModelsForApi(paths);
|
|
2648
|
+
const providerMatch = providerModels.find((entry) => entry.id === model || entry.aliases.includes(model));
|
|
2649
|
+
if (providerMatch) {
|
|
2650
|
+
return providerMatch.endpoint_type;
|
|
2651
|
+
}
|
|
2652
|
+
const endpoints = await listEndpoints(paths);
|
|
2653
|
+
const match = endpoints.find((endpoint) =>
|
|
2654
|
+
endpoint.models.some((entry) => entry.publicName === model)
|
|
2655
|
+
);
|
|
2656
|
+
if (match) {
|
|
2657
|
+
return match.type;
|
|
2658
|
+
}
|
|
2659
|
+
if (isImageModel(model)) return "diffusion";
|
|
2660
|
+
if (isAudioModel(model)) return "audio";
|
|
2661
|
+
if (isVideoModel(model)) return "video";
|
|
2662
|
+
return "llm";
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
function normalizeType(value: string): "llm" | "diffusion" | "audio" | "embedding" | "video" {
|
|
2666
|
+
if (value === "diffusion") return "diffusion";
|
|
2667
|
+
if (value === "audio") return "audio";
|
|
2668
|
+
if (value === "embedding") return "embedding";
|
|
2669
|
+
if (value === "video") return "video";
|
|
2670
|
+
return "llm";
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
async function readResponsePayload(response: { body: NodeJS.ReadableStream; headers: Record<string, string | string[]> }): Promise<unknown> {
|
|
2674
|
+
const chunks: Buffer[] = [];
|
|
2675
|
+
for await (const chunk of response.body) {
|
|
2676
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2677
|
+
}
|
|
2678
|
+
const buffer = Buffer.concat(chunks);
|
|
2679
|
+
const contentType = normalizeHeaders(response.headers)["content-type"] ?? "";
|
|
2680
|
+
if (contentType.includes("application/json")) {
|
|
2681
|
+
try {
|
|
2682
|
+
return JSON.parse(buffer.toString("utf8"));
|
|
2683
|
+
} catch {
|
|
2684
|
+
return buffer.toString("utf8");
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
return buffer.toString("utf8");
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
function normalizeHeaders(headers: Record<string, string | string[]>): Record<string, string> {
|
|
2691
|
+
const normalized: Record<string, string> = {};
|
|
2692
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
2693
|
+
normalized[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
|
|
2694
|
+
}
|
|
2695
|
+
return normalized;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
function readPid(filePath: string): number | null {
|
|
2699
|
+
try {
|
|
2700
|
+
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
2701
|
+
const pid = Number(raw);
|
|
2702
|
+
return Number.isFinite(pid) ? pid : null;
|
|
2703
|
+
} catch {
|
|
2704
|
+
return null;
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
function isRunning(filePath: string): boolean {
|
|
2709
|
+
const pid = readPid(filePath);
|
|
2710
|
+
if (!pid) {
|
|
2711
|
+
return false;
|
|
2712
|
+
}
|
|
2713
|
+
try {
|
|
2714
|
+
process.kill(pid, 0);
|
|
2715
|
+
return true;
|
|
2716
|
+
} catch {
|
|
2717
|
+
return false;
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
function summarizeProviderHealth(
|
|
2722
|
+
models: ProviderModelRecord[],
|
|
2723
|
+
healthMap: Record<string, { status?: string }>
|
|
2724
|
+
): string {
|
|
2725
|
+
const enabled = models.filter((model) => model.enabled !== false);
|
|
2726
|
+
let up = 0;
|
|
2727
|
+
let down = 0;
|
|
2728
|
+
for (const model of enabled) {
|
|
2729
|
+
const health = healthMap[model.providerModelId];
|
|
2730
|
+
if (health?.status === "up") {
|
|
2731
|
+
up += 1;
|
|
2732
|
+
} else if (health?.status === "down") {
|
|
2733
|
+
down += 1;
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
return `${up}/${down}/${enabled.length}`;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
function formatLatency(latency?: number): string {
|
|
2740
|
+
if (!latency || !Number.isFinite(latency)) {
|
|
2741
|
+
return "-";
|
|
2742
|
+
}
|
|
2743
|
+
return `${Math.round(latency)}ms`;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
async function startService(): Promise<void> {
|
|
2747
|
+
await ensureStorageDir(paths);
|
|
2748
|
+
const existingPid = readPid(pidFile);
|
|
2749
|
+
if (existingPid) {
|
|
2750
|
+
if (isRunning(pidFile)) {
|
|
2751
|
+
console.log(`${DISPLAY_NAME} is already running.`);
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
fs.unlinkSync(pidFile);
|
|
2755
|
+
}
|
|
2756
|
+
const rootDir = getPackageRoot();
|
|
2757
|
+
const entry = path.join(rootDir, "dist", "src", "index.js");
|
|
2758
|
+
if (!fs.existsSync(entry)) {
|
|
2759
|
+
console.error(`Missing ${entry}. Run npm run build first.`);
|
|
2760
|
+
process.exitCode = 1;
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
const child = spawn(process.execPath, [entry], {
|
|
2764
|
+
detached: true,
|
|
2765
|
+
stdio: "ignore",
|
|
2766
|
+
env: {
|
|
2767
|
+
...process.env
|
|
2768
|
+
}
|
|
2769
|
+
});
|
|
2770
|
+
if (!child.pid) {
|
|
2771
|
+
console.error(`Failed to start ${DISPLAY_NAME}.`);
|
|
2772
|
+
process.exitCode = 1;
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
child.unref();
|
|
2776
|
+
fs.writeFileSync(pidFile, String(child.pid), "utf8");
|
|
2777
|
+
console.log(`${DISPLAY_NAME} started (pid ${child.pid}).`);
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
async function stopService(): Promise<void> {
|
|
2781
|
+
await ensureStorageDir(paths);
|
|
2782
|
+
const pid = readPid(pidFile);
|
|
2783
|
+
if (!pid) {
|
|
2784
|
+
console.log(`${DISPLAY_NAME} is not running.`);
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
try {
|
|
2788
|
+
process.kill(pid);
|
|
2789
|
+
fs.unlinkSync(pidFile);
|
|
2790
|
+
console.log(`${DISPLAY_NAME} stopped.`);
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
console.error(`Failed to stop: ${(error as Error).message}`);
|
|
2793
|
+
process.exitCode = 1;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
function getPackageRoot(): string {
|
|
2798
|
+
const dir = __dirname;
|
|
2799
|
+
const base = path.basename(dir);
|
|
2800
|
+
const parent = path.basename(path.dirname(dir));
|
|
2801
|
+
if (base === "cli" && parent === "dist") {
|
|
2802
|
+
return path.resolve(dir, "..", "..");
|
|
2803
|
+
}
|
|
2804
|
+
return path.resolve(dir, "..");
|
|
2805
|
+
}
|