codex-dev-mcp-suite 1.3.0 → 1.5.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/.env.example +19 -0
- package/CHANGELOG.md +52 -0
- package/README.md +79 -1
- package/bin/provider-smoke.mjs +278 -0
- package/lib/provider-smoke.js +234 -0
- package/package.json +3 -2
- package/project-memory/dedup.js +49 -0
- package/project-memory/embedding.js +108 -30
- package/project-memory/global-index.js +46 -0
- package/project-memory/graph.js +86 -0
- package/project-memory/server.js +197 -11
package/.env.example
CHANGED
|
@@ -40,6 +40,25 @@ MCP_RERANK_MODEL=llama3.1:8b
|
|
|
40
40
|
RERANK_TIMEOUT_MS=30000
|
|
41
41
|
RERANK_ENABLED=1
|
|
42
42
|
|
|
43
|
+
|
|
44
|
+
# === Cloudflare Workers AI (REST-only, NOT OpenAI-compatible) ===
|
|
45
|
+
# Free tier: 10,000 neurons/day. No laptop resource usage.
|
|
46
|
+
# To get started: dash.cloudflare.com → Workers & Pages → Create Worker → Workers AI → enable
|
|
47
|
+
# Account ID: from dashboard sidebar. API Token: My Profile → API Tokens → "Edit Cloudflare Workers".
|
|
48
|
+
# Note: base URL has trailing slash; model name goes in URL path, not body.
|
|
49
|
+
# Set CLOUDFLARE_EMBED_MODEL (e.g. @cf/baai/bge-base-en-v1.5) to enable semantic recall
|
|
50
|
+
# via Cloudflare instead of 9router/OpenAI/etc.
|
|
51
|
+
# MCP_EMBED_BASE_URL=https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/run/
|
|
52
|
+
# MCP_EMBED_API_KEY=<cf-token>
|
|
53
|
+
# MCP_EMBED_MODEL=@cf/baai/bge-base-en-v1.5
|
|
54
|
+
|
|
55
|
+
# === Google Gemini (OpenAI-compatible, generous free tier) ===
|
|
56
|
+
# Get key: https://aistudio.google.com/app/apikey
|
|
57
|
+
# Free tier: 60 req/min, 1500/day for text-embedding-004
|
|
58
|
+
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
|
|
59
|
+
# GEMINI_API_KEY=<gemini-key>
|
|
60
|
+
# GEMINI_MODEL=text-embedding-004
|
|
61
|
+
|
|
43
62
|
# Storage locations (defaults shown; override if you want)
|
|
44
63
|
# MEMORY_VAULT_DIR=~/.codex/memories/vault
|
|
45
64
|
# JOURNAL_DIR=~/.codex/memories/journal
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
|
+
## 1.5.0
|
|
2
|
+
|
|
3
|
+
- add `memory_link` with wiki-link resolution for `[[id]]`, `[[title]]`, `[[project:id]]`, and `[[project:title]]`, plus backlink inspection
|
|
4
|
+
- add `memory_global_recall` with same-project bias and graceful keyword/semantic fallback across project vaults
|
|
5
|
+
- add `memory_dedup` with non-destructive duplicate suggestions for project or global scope
|
|
6
|
+
- add hybrid lazy graph backfill so link metadata is derived on first graph-aware use and remains rebuildable from canonical notes
|
|
7
|
+
- add graph-aware soft boost in recall paths without making embeddings mandatory
|
|
8
|
+
- 36 project-memory tests pass after graph/global/dedup coverage expansion
|
|
9
|
+
|
|
1
10
|
# Changelog
|
|
2
11
|
|
|
12
|
+
## 1.4.0 - 2026-06-17
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **`provider-smoke` CLI** (`bin/provider-smoke.mjs`): probe every configured LLM provider (chat + embeddings) and print a matrix of latency / status / sample. Useful after adding a new API key or comparing providers.
|
|
16
|
+
- Usage: `npx -y -p codex-dev-mcp-suite provider-smoke` (or globally `provider-smoke`)
|
|
17
|
+
- Flags: `--json`, `--markdown`, `--save-md <path>`, `--chat-only`, `--embed-only`, `--env-file <path>`, `--only a,b,c`, `--timeout <ms>`, `--help`, `--version`
|
|
18
|
+
- Auto-detects providers from `MCP_PROVIDER_*` slots + named env (`GROQ_*`, `CEREBRAS_*`, `MISTRAL_*`, `OPENROUTER_*`, `OPENAI_*`, `OLLAMA_*`, `ANTHROPIC_*`, `GEMINI_*`, `COHERE_*`, `VOYAGE_*`, `CLOUDFLARE_*`) + `MCP_LLM_BASE_URL` / `NINEROUTER_URL` fallback.
|
|
19
|
+
- Pure-function library at `lib/provider-smoke.js` (18 offline tests).
|
|
20
|
+
- `KNOWN_PROVIDERS` registry covers 11 providers with `supportsChat` / `supportsEmbed` flags + `endpoint: "openai-compatible" | "cloudflare"`.
|
|
21
|
+
- Cloudflare embed-only models (`@cf/baai/*`, `@cf/google/embedding*`, etc.) are detected and the chat probe is skipped with a clear annotation.
|
|
22
|
+
- **Cloudflare Workers AI support** for embeddings: drop-in alternative when 9router/OpenAI are unavailable. Set `MCP_EMBED_BASE_URL=https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/run/`, `MCP_EMBED_API_KEY=<token>`, `MCP_EMBED_MODEL=@cf/baai/bge-base-en-v1.5`.
|
|
23
|
+
- Cloudflare's REST API (non-OpenAI-compatible, model-in-path) is auto-detected by URL pattern and dispatched to a separate code path in `project-memory/embedding.js`.
|
|
24
|
+
- New `isCloudflareURL()` + `httpPostJson()` helpers; `embedCloudflare()` handles `{text: [...]}` body and `{result: {data: [[...]]}}` response.
|
|
25
|
+
- `embeddingConfig().mode` and `recallMode()` helpers expose what retrieval mode is active (`semantic` / `keyword` / `deterministic`).
|
|
26
|
+
- **`memory_recall` mode arg** (default `"auto"`): explicit control over fallback behavior. `mode: "semantic"` requires embedding (returns `isError: true` if unavailable), `mode: "keyword"` skips embedding entirely.
|
|
27
|
+
- **Recall output annotation** now shows `[semantic+rerank]` / `[keyword+rerank]` when LLM rerank is active, making the active mode fully transparent.
|
|
28
|
+
- **Top-level test runner hook** (added in 1.3.0, expanded in 1.4.0): `tests/*.test.mjs` are auto-picked-up by `node run-tests.mjs`.
|
|
29
|
+
|
|
30
|
+
### Tests
|
|
31
|
+
- 85 tests pass (was 60 in v1.3.0): 26 project-memory + 6 checkpoint + 6 context-pack + 11 devjournal + 18 provider-smoke + 9 prune + 9 stats.
|
|
32
|
+
- New fallback tests: `mode=keyword` skips semantic, `mode=semantic` without embed key returns isError, default mode=auto, no `+rerank` suffix when rerank disabled.
|
|
33
|
+
|
|
34
|
+
### Docs
|
|
35
|
+
- `docs/providers.md` rewritten: 11-provider matrix, Cloudflare setup caveat, Ollama resource cost, "no API key" graceful degradation notes.
|
|
36
|
+
- `README.md` updated: "Memory Recall Modes" section, Cloudflare + Gemini setup examples, 4-mode annotation examples.
|
|
37
|
+
- `.env.example` updated: copy-pasteable config blocks for Cloudflare Workers AI and Google Gemini.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
3
41
|
## 1.3.0 - 2026-06-17
|
|
4
42
|
|
|
5
43
|
### Added
|
|
@@ -8,6 +46,14 @@
|
|
|
8
46
|
- Flags: `--root <path>`, `--json`, `--top N`, `--help`, `--version`
|
|
9
47
|
- Pure-function library at `lib/stats.js` (no MCP / no stdio); tested offline.
|
|
10
48
|
- New top-level test runner hook: `tests/*.test.mjs` are auto-picked-up by `node run-tests.mjs`.
|
|
49
|
+
- New `provider-smoke` CLI: probe every configured LLM provider (chat + embeddings) and print a matrix of latency / status / sample. Useful after adding a new API key or to compare providers.
|
|
50
|
+
- Usage: `npx -y -p codex-dev-mcp-suite provider-smoke`
|
|
51
|
+
- Flags: `--json`, `--markdown`, `--save-md <path>`, `--chat-only`, `--embed-only`, `--env-file <path>`, `--timeout <ms>`
|
|
52
|
+
- Detects providers from `MCP_PROVIDER_*` slots + named env (`GROQ_*`, `CEREBRAS_*`, `MISTRAL_*`, `OPENROUTER_*`, `OPENAI_*`, `OLLAMA_*`, `ANTHROPIC_*`, `GEMINI_*`, `COHERE_*`) + `MCP_LLM_BASE_URL` / `NINEROUTER_URL` fallback.
|
|
53
|
+
- Pure-function library at `lib/provider-smoke.js`; tested offline.
|
|
54
|
+
- New docs page `docs/providers.md` — auto-generated smoke matrix from latest run.
|
|
55
|
+
|
|
56
|
+
|
|
11
57
|
|
|
12
58
|
|
|
13
59
|
|
|
@@ -21,6 +67,12 @@
|
|
|
21
67
|
- New MCP tool `memory_stats` on the project-memory server: returns the same summary as the `stats` CLI. Useful for in-session introspection from an MCP client.
|
|
22
68
|
- Args: `root?`, `top?` (default 10), `json?` (default false).
|
|
23
69
|
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
- **Cloudflare Workers AI support** for embeddings and chat: drop-in alternative when 9router/OpenAI are unavailable. Set `MCP_EMBED_BASE_URL=https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/ai/run/`, `MCP_EMBED_API_KEY=<token>`, `MCP_EMBED_MODEL=@cf/baai/bge-base-en-v1.5`. Cloudflare's REST API (non-OpenAI-compatible) is auto-detected by URL pattern and dispatched to a separate code path in `project-memory/embedding.js`. Includes `isCloudflareURL()` detection + `endpoint: "cloudflare"` field in provider matrix.
|
|
74
|
+
- `provider-smoke` CLI: probe Cloudflare alongside OpenAI-compatible providers. Cloudflare chat returns 400 for embed-only models (expected — they don't support chat).
|
|
75
|
+
|
|
24
76
|
## 1.2.0 - 2026-06-15
|
|
25
77
|
|
|
26
78
|
### Added
|
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ Other clients (Claude Code, Cursor, Cline, ...): see
|
|
|
66
66
|
|
|
67
67
|
| Server | What it does | Key tools |
|
|
68
68
|
|---|---|---|
|
|
69
|
-
| **project-memory** | Searchable Markdown knowledge vault (Obsidian-style notes + on-demand recall). Notes are also exposed as MCP resources. | `memory_save`, `memory_recall`, `memory_list`, `memory_get`, `memory_delete`, `memory_reindex` |
|
|
69
|
+
| **project-memory** | Searchable Markdown knowledge vault (Obsidian-style notes + on-demand recall). Notes are also exposed as MCP resources. | `memory_save`, `memory_recall`, `memory_list`, `memory_get`, `memory_delete`, `memory_reindex`, `memory_link`, `memory_global_recall`, `memory_dedup` |
|
|
70
70
|
| **devjournal** | Per-project session timeline + handoff/resume (anti-compaction). | `journal_log`, `journal_handoff`, `journal_resume`, `journal_timeline`, `journal_search`, `journal_clear_handoff` |
|
|
71
71
|
| **checkpoint** | Git-independent file snapshots for safe experimentation. | `checkpoint_create`, `checkpoint_list`, `checkpoint_diff`, `checkpoint_restore`, `checkpoint_delete` |
|
|
72
72
|
| **context-pack** | Token-efficient project briefing (stack, tree, symbols, search). | `pack_overview`, `pack_tree`, `pack_outline`, `pack_search` |
|
|
@@ -168,6 +168,40 @@ idea — use the npm bin commands (`project-memory-mcp`, `devjournal-mcp`,
|
|
|
168
168
|
`checkpoint-mcp`, `context-pack-mcp`) or `node /abs/path/<server>/server.js`,
|
|
169
169
|
with optional `env`.
|
|
170
170
|
|
|
171
|
+
## Memory Recall Modes
|
|
172
|
+
|
|
173
|
+
`memory_recall` supports a `mode` arg (default `"auto"`) that controls fallback behavior:
|
|
174
|
+
|
|
175
|
+
| Mode | Behavior |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `auto` (default) | Smart: try semantic → keyword → LLM rerank |
|
|
178
|
+
| `semantic` | Require embedding. Returns `isError: true` if no embed key configured |
|
|
179
|
+
| `keyword` | Skip embedding entirely (faster, pure keyword scoring) |
|
|
180
|
+
|
|
181
|
+
The recall output annotates the active mode for transparency:
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
Recall for "how do users sign in" in my-project [semantic+rerank]:
|
|
185
|
+
### JWT login flow (id:..., sim:0.603, ...)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The `+rerank` suffix is appended when LLM rerank is active (MCP_RERANK_ENABLED + key).
|
|
189
|
+
Without embeddings, recall falls back to `[keyword]` or `[keyword+rerank]`.
|
|
190
|
+
With `MCP_DETERMINISTIC_FALLBACK=true`, the mode is always `[deterministic]`.
|
|
191
|
+
|
|
192
|
+
This means you can run codex-dev-mcp-suite with **zero API keys configured** —
|
|
193
|
+
`memory_recall` still works via keyword scoring, just no semantic similarity.
|
|
194
|
+
|
|
195
|
+
### New in v1.5.0
|
|
196
|
+
|
|
197
|
+
`project-memory` now adds a lightweight knowledge-graph layer:
|
|
198
|
+
|
|
199
|
+
- `memory_link` resolves wiki-style links like `[[id]]`, `[[title]]`, and `[[project:title]]`, and shows backlinks
|
|
200
|
+
- `memory_global_recall` searches across projects with same-project bias and graceful keyword fallback
|
|
201
|
+
- `memory_dedup` suggests duplicate-note merges without deleting anything
|
|
202
|
+
|
|
203
|
+
Link resolution always prefers the active project first, then falls back globally when appropriate.
|
|
204
|
+
|
|
171
205
|
## Daily workflow
|
|
172
206
|
|
|
173
207
|
- Session start: `pack_overview` + `journal_resume` + `memory_recall "<topic>"`
|
|
@@ -230,6 +264,50 @@ directly from your own scripts. The default storage root is
|
|
|
230
264
|
`JOURNAL_DIR` / `CHECKPOINT_DIR` env vars) override it.
|
|
231
265
|
|
|
232
266
|
|
|
267
|
+
## Provider Smoke CLI
|
|
268
|
+
|
|
269
|
+
Verify every configured LLM provider (chat + embeddings) with one command.
|
|
270
|
+
Useful after adding a new API key, or when comparing provider latency.
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
npx -y -p codex-dev-mcp-suite provider-smoke # uses process.env
|
|
274
|
+
npx -y -p codex-dev-mcp-suite provider-smoke --env-file ~/secrets.env # separate secrets file
|
|
275
|
+
npx -y -p codex-dev-mcp-suite provider-smoke --markdown --save-md docs/providers.md
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Auto-detects providers from these env vars (highest precedence first):
|
|
279
|
+
|
|
280
|
+
- **Numbered slots**: `MCP_PROVIDER_PRIMARY` / `_CHAIN2` / `_CHAIN3` / ...
|
|
281
|
+
- **Named env**: `GROQ_*`, `CEREBRAS_*`, `MISTRAL_*`, `OPENROUTER_*`, `OPENAI_*`, `GEMINI_*`, `COHERE_*`, `VOYAGE_*`, `OLLAMA_*`, `ANTHROPIC_*`
|
|
282
|
+
- **Catch-all**: `MCP_LLM_BASE_URL` / `MCP_RERANK_BASE_URL` / `MCP_EMBED_BASE_URL` / `NINEROUTER_URL` / `LLM_BASE_URL` (any OpenAI-compatible endpoint)
|
|
283
|
+
|
|
284
|
+
For each detected provider, runs:
|
|
285
|
+
- **chat probe** (`/v1/chat/completions`) — if the provider supports it
|
|
286
|
+
- **embed probe** (`/v1/embeddings`) — if the provider supports it
|
|
287
|
+
|
|
288
|
+
**Inference-only providers** (Groq, Cerebras, Anthropic) skip the embed probe automatically.
|
|
289
|
+
**Non-OpenAI-compatible providers** (Cloudflare Workers AI) require a custom code path in
|
|
290
|
+
`project-memory/embedding.js` — see [docs/providers.md](docs/providers.md).
|
|
291
|
+
|
|
292
|
+
**No provider assumption is baked in**: the tool only probes what's in your env. Zero-config users
|
|
293
|
+
get an empty matrix; pass `--env-file` to point at a secrets file.
|
|
294
|
+
|
|
295
|
+
Chat-only providers (Groq, Cerebras) skip the embeddings probe automatically
|
|
296
|
+
— they don't expose `/v1/embeddings`. Embedding-capable providers (Mistral,
|
|
297
|
+
OpenRouter, OpenAI, Ollama, Gemini, Cohere) run both probes.
|
|
298
|
+
|
|
299
|
+
Example output (Groq + Cerebras, both configured):
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
[groq]
|
|
303
|
+
✓ chat 200 310ms "OK"
|
|
304
|
+
|
|
305
|
+
[cerebras]
|
|
306
|
+
✓ chat 200 476ms
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
See [`docs/providers.md`](docs/providers.md) for a saved smoke matrix.
|
|
310
|
+
|
|
233
311
|
## Prune CLI
|
|
234
312
|
|
|
235
313
|
Remove temp project slugs (prefix `tmp.`) from vault / journal / checkpoints.
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bin/provider-smoke.mjs — Dev MCP Suite provider smoke test.
|
|
4
|
+
*
|
|
5
|
+
* Probes every provider that can be derived from the current env:
|
|
6
|
+
* - MCP_LLM_BASE_URL / NINEROUTER_URL / LLM_BASE_URL (9router-like)
|
|
7
|
+
* - MCP_PROVIDER_PRIMARY / _CHAIN2 / _CHAIN3 / ... (numbered slots)
|
|
8
|
+
* - Named: GROQ_*, CEREBRAS_*, MISTRAL_*, OPENROUTER_*, OPENAI_*, OLLAMA_*, ...
|
|
9
|
+
*
|
|
10
|
+
* For each provider, runs:
|
|
11
|
+
* - chat probe (/v1/chat/completions, short prompt) if supportsChat
|
|
12
|
+
* - embed probe (/v1/embeddings, single string) if supportsEmbed
|
|
13
|
+
*
|
|
14
|
+
* Output: human text by default; --json for machines; --markdown for docs.
|
|
15
|
+
*
|
|
16
|
+
* Env override:
|
|
17
|
+
* --env-file <path> Source env from this file before probing (e.g. /tmp/secrets.env)
|
|
18
|
+
*
|
|
19
|
+
* Exit code:
|
|
20
|
+
* 0 = all probes ok
|
|
21
|
+
* 1 = at least one probe failed
|
|
22
|
+
*/
|
|
23
|
+
import fs from "fs";
|
|
24
|
+
import os from "os";
|
|
25
|
+
import path from "path";
|
|
26
|
+
import { fileURLToPath } from "url";
|
|
27
|
+
import { buildProviderMatrix, shapeProbe, formatText, formatJson, formatMarkdown, isCloudflareURL } from "../lib/provider-smoke.js";
|
|
28
|
+
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
|
|
31
|
+
function pkgVersion() {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).version || "unknown";
|
|
34
|
+
} catch { return "unknown"; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
/** Strip a trailing /v1 (only the literal segment, not /openai/v1) so we can re-append /v1/<endpoint>. */
|
|
39
|
+
function stripV1(baseUrl) {
|
|
40
|
+
return String(baseUrl || "").replace(/\/v1\/?$/, "").replace(/\/$/, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseArgs(argv) {
|
|
44
|
+
const opts = {
|
|
45
|
+
help: false, version: false,
|
|
46
|
+
json: false, markdown: false,
|
|
47
|
+
saveMd: null, envFile: null,
|
|
48
|
+
chatOnly: false, embedOnly: false,
|
|
49
|
+
timeoutMs: 15000,
|
|
50
|
+
only: null,
|
|
51
|
+
};
|
|
52
|
+
for (let i = 0; i < argv.length; i++) {
|
|
53
|
+
const a = argv[i];
|
|
54
|
+
if (a === "-h" || a === "--help") opts.help = true;
|
|
55
|
+
else if (a === "-v" || a === "--version") opts.version = true;
|
|
56
|
+
else if (a === "--json") opts.json = true;
|
|
57
|
+
else if (a === "--markdown") opts.markdown = true;
|
|
58
|
+
else if (a === "--save-md") opts.saveMd = argv[++i];
|
|
59
|
+
else if (a === "--env-file") opts.envFile = argv[++i];
|
|
60
|
+
else if (a === "--chat-only") opts.chatOnly = true;
|
|
61
|
+
else if (a === "--embed-only") opts.embedOnly = true;
|
|
62
|
+
else if (a === "--timeout") opts.timeoutMs = parseInt(argv[++i], 10) || 15000;
|
|
63
|
+
else if (a === "--only") opts.only = (argv[++i] || "").split(",").map(s => s.trim()).filter(Boolean);
|
|
64
|
+
else { process.stderr.write(`provider-smoke: unknown flag: ${a}\n`); process.exit(2); }
|
|
65
|
+
}
|
|
66
|
+
return opts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function printHelp() {
|
|
70
|
+
const out = [
|
|
71
|
+
`provider-smoke (Dev MCP Suite) v${pkgVersion()}`,
|
|
72
|
+
"",
|
|
73
|
+
"Probe every configured LLM provider (chat + embeddings) and print a matrix.",
|
|
74
|
+
"",
|
|
75
|
+
"Usage:",
|
|
76
|
+
" provider-smoke [--json] [--markdown] [--save-md <path>]",
|
|
77
|
+
" [--chat-only | --embed-only] [--env-file <path>]",
|
|
78
|
+
" provider-smoke --help | --version",
|
|
79
|
+
"",
|
|
80
|
+
"Options:",
|
|
81
|
+
" --json Machine-readable JSON output",
|
|
82
|
+
" --markdown Markdown table (for docs/)",
|
|
83
|
+
" --save-md <path> Write markdown report to this file",
|
|
84
|
+
" --env-file <path> Source env vars from this file before probing",
|
|
85
|
+
" (KEY=value lines, one per line)",
|
|
86
|
+
" --chat-only Only probe /v1/chat/completions",
|
|
87
|
+
" --embed-only Only probe /v1/embeddings",
|
|
88
|
+
" --timeout <ms> Per-probe timeout (default 15000)",
|
|
89
|
+
" --only a,b,c Only probe these provider ids (comma-separated); others ignored",
|
|
90
|
+
"",
|
|
91
|
+
"Detected providers (precedence):",
|
|
92
|
+
" MCP_PROVIDER_PRIMARY / _CHAIN2 / _CHAIN3 / ... (numbered slots)",
|
|
93
|
+
" GROQ_*, CEREBRAS_*, MISTRAL_*, OPENROUTER_*, (named)",
|
|
94
|
+
" OPENAI_*, OLLAMA_*, ANTHROPIC_*, GEMINI_*, COHERE_*",
|
|
95
|
+
" MCP_LLM_BASE_URL / NINEROUTER_URL / LLM_BASE_URL (9router / agentrouter)",
|
|
96
|
+
"",
|
|
97
|
+
"Examples:",
|
|
98
|
+
" provider-smoke",
|
|
99
|
+
" provider-smoke --env-file /tmp/codex-dev-smoke.env",
|
|
100
|
+
" provider-smoke --json | jq '.[] | select(.ok == false)'",
|
|
101
|
+
" provider-smoke --markdown --save-md docs/providers.md",
|
|
102
|
+
];
|
|
103
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function loadEnvFile(file) {
|
|
107
|
+
if (!file) return {};
|
|
108
|
+
const text = fs.readFileSync(file, "utf8");
|
|
109
|
+
const env = {};
|
|
110
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
111
|
+
const line = raw.trim();
|
|
112
|
+
if (!line || line.startsWith("#")) continue;
|
|
113
|
+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
114
|
+
if (m) env[m[1]] = m[2];
|
|
115
|
+
}
|
|
116
|
+
return env;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildHeaders(provider) {
|
|
120
|
+
const h = { "Content-Type": "application/json" };
|
|
121
|
+
if (provider.apiKey) h["Authorization"] = `Bearer ${provider.apiKey}`;
|
|
122
|
+
return h;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
const CF_EMBED_ONLY_PREFIXES = ["@cf/baai/", "@cf/google/embedding", "@cf/baai/sentence-"];
|
|
127
|
+
|
|
128
|
+
function isEmbedOnlyModel(model) {
|
|
129
|
+
if (!model) return false;
|
|
130
|
+
return CF_EMBED_ONLY_PREFIXES.some((p) => model.startsWith(p));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function probeCloudflare(provider, timeoutMs, kind) {
|
|
134
|
+
// Skip chat probe for known embed-only Cloudflare model prefixes (annotate, not error).
|
|
135
|
+
if (kind === "chat" && isEmbedOnlyModel(provider.chatModel || provider.embedModel)) {
|
|
136
|
+
return shapeProbe({ name: provider.id, kind: "chat", ok: false, error: "embed-only model (chat not supported)" });
|
|
137
|
+
}
|
|
138
|
+
// Cloudflare Workers AI REST: POST {baseUrl}/{model}, body shape depends on kind.
|
|
139
|
+
// Cloudflare model names contain "/" (e.g. "@cf/baai/bge-base-en-v1.5"); do NOT URL-encode.
|
|
140
|
+
const url = `${provider.baseUrl.replace(/\/$/, "")}/${provider.chatModel || provider.embedModel}`;
|
|
141
|
+
let body;
|
|
142
|
+
if (kind === "chat") {
|
|
143
|
+
body = JSON.stringify({
|
|
144
|
+
messages: [{ role: "user", content: "Say the single word: OK" }],
|
|
145
|
+
max_tokens: 8,
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
body = JSON.stringify({ text: ["ping"] });
|
|
149
|
+
}
|
|
150
|
+
const t0 = Date.now();
|
|
151
|
+
try {
|
|
152
|
+
const res = await fetch(url, { method: "POST", headers: buildHeaders(provider), body, signal: AbortSignal.timeout(timeoutMs) });
|
|
153
|
+
const text = await res.text();
|
|
154
|
+
const latencyMs = Date.now() - t0;
|
|
155
|
+
if (res.status !== 200) {
|
|
156
|
+
return shapeProbe({ name: provider.id, kind, ok: false, status: res.status, latencyMs, error: text.slice(0, 200) });
|
|
157
|
+
}
|
|
158
|
+
let sample = null, dim = null;
|
|
159
|
+
try {
|
|
160
|
+
const j = JSON.parse(text);
|
|
161
|
+
if (kind === "embed") {
|
|
162
|
+
const data = j?.result?.data;
|
|
163
|
+
if (Array.isArray(data) && Array.isArray(data[0])) dim = data[0].length;
|
|
164
|
+
} else {
|
|
165
|
+
sample = j?.result?.response ?? null;
|
|
166
|
+
}
|
|
167
|
+
} catch { /* ignore */ }
|
|
168
|
+
return shapeProbe({ name: provider.id, kind, ok: true, status: res.status, latencyMs, sample, dim });
|
|
169
|
+
} catch (e) {
|
|
170
|
+
return shapeProbe({ name: provider.id, kind, ok: false, latencyMs: Date.now() - t0, error: String(e && e.message || e) });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function probeChat(provider, timeoutMs) {
|
|
175
|
+
if (!provider.baseUrl || !provider.chatModel) {
|
|
176
|
+
return shapeProbe({ name: provider.id, kind: "chat", ok: false, error: "missing baseUrl or chatModel" });
|
|
177
|
+
}
|
|
178
|
+
const endpoint = provider.endpoint || (isCloudflareURL(provider.baseUrl) ? "cloudflare" : "openai-compatible");
|
|
179
|
+
if (endpoint === "cloudflare") return probeCloudflare(provider, timeoutMs, "chat");
|
|
180
|
+
const url = `${stripV1(provider.baseUrl)}/v1/chat/completions`;
|
|
181
|
+
const body = JSON.stringify({
|
|
182
|
+
model: provider.chatModel,
|
|
183
|
+
messages: [{ role: "user", content: "Say the single word: OK" }],
|
|
184
|
+
max_tokens: 8,
|
|
185
|
+
temperature: 0,
|
|
186
|
+
});
|
|
187
|
+
const t0 = Date.now();
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(url, { method: "POST", headers: buildHeaders(provider), body, signal: AbortSignal.timeout(timeoutMs) });
|
|
190
|
+
const text = await res.text();
|
|
191
|
+
const latencyMs = Date.now() - t0;
|
|
192
|
+
if (res.status !== 200) {
|
|
193
|
+
return shapeProbe({ name: provider.id, kind: "chat", ok: false, status: res.status, latencyMs, error: text.slice(0, 200) });
|
|
194
|
+
}
|
|
195
|
+
let sample = null;
|
|
196
|
+
try {
|
|
197
|
+
const j = JSON.parse(text);
|
|
198
|
+
sample = j?.choices?.[0]?.message?.content ?? null;
|
|
199
|
+
} catch { /* ignore */ }
|
|
200
|
+
return shapeProbe({ name: provider.id, kind: "chat", ok: true, status: res.status, latencyMs, sample });
|
|
201
|
+
} catch (e) {
|
|
202
|
+
return shapeProbe({ name: provider.id, kind: "chat", ok: false, latencyMs: Date.now() - t0, error: String(e && e.message || e) });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function probeEmbed(provider, timeoutMs) {
|
|
207
|
+
if (!provider.supportsEmbed) {
|
|
208
|
+
return shapeProbe({ name: provider.id, kind: "embed", ok: false, error: "provider has no /v1/embeddings endpoint" });
|
|
209
|
+
}
|
|
210
|
+
if (!provider.baseUrl || !provider.embedModel) {
|
|
211
|
+
return shapeProbe({ name: provider.id, kind: "embed", ok: false, error: "missing baseUrl or embedModel" });
|
|
212
|
+
}
|
|
213
|
+
if (provider.endpoint === "cloudflare" || isCloudflareURL(provider.baseUrl)) return probeCloudflare(provider, timeoutMs, "embed");
|
|
214
|
+
const url = `${stripV1(provider.baseUrl)}/v1/embeddings`;
|
|
215
|
+
const body = JSON.stringify({ model: provider.embedModel, input: "ping" });
|
|
216
|
+
const t0 = Date.now();
|
|
217
|
+
try {
|
|
218
|
+
const res = await fetch(url, { method: "POST", headers: buildHeaders(provider), body, signal: AbortSignal.timeout(timeoutMs) });
|
|
219
|
+
const text = await res.text();
|
|
220
|
+
const latencyMs = Date.now() - t0;
|
|
221
|
+
if (res.status !== 200) {
|
|
222
|
+
return shapeProbe({ name: provider.id, kind: "embed", ok: false, status: res.status, latencyMs, error: text.slice(0, 200) });
|
|
223
|
+
}
|
|
224
|
+
let dim = null;
|
|
225
|
+
try {
|
|
226
|
+
const j = JSON.parse(text);
|
|
227
|
+
dim = Array.isArray(j?.data?.[0]?.embedding) ? j.data[0].embedding.length : null;
|
|
228
|
+
} catch { /* ignore */ }
|
|
229
|
+
return shapeProbe({ name: provider.id, kind: "embed", ok: true, status: res.status, latencyMs, dim });
|
|
230
|
+
} catch (e) {
|
|
231
|
+
return shapeProbe({ name: provider.id, kind: "embed", ok: false, latencyMs: Date.now() - t0, error: String(e && e.message || e) });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
236
|
+
if (opts.version) { process.stdout.write(`${pkgVersion()}\n`); process.exit(0); }
|
|
237
|
+
if (opts.help) { printHelp(); process.exit(0); }
|
|
238
|
+
|
|
239
|
+
// Merge env: process.env + (optional) env file
|
|
240
|
+
const fileEnv = loadEnvFile(opts.envFile);
|
|
241
|
+
const env = { ...process.env, ...fileEnv };
|
|
242
|
+
|
|
243
|
+
let matrix = buildProviderMatrix(env);
|
|
244
|
+
if (opts.only && opts.only.length) {
|
|
245
|
+
matrix = matrix.filter(p => opts.only.includes(p.id));
|
|
246
|
+
}
|
|
247
|
+
if (matrix.length === 0) {
|
|
248
|
+
const hint = opts.only && opts.only.length ? " (--only filter: " + opts.only.join(",") + ")" : "";
|
|
249
|
+
process.stderr.write("No providers detected. Set MCP_PROVIDER_PRIMARY / GROQ_API_KEY / NINEROUTER_URL etc., or pass --env-file." + hint + "\n");
|
|
250
|
+
process.exit(2);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
process.stderr.write(`Probing ${matrix.length} provider${matrix.length === 1 ? "" : "s"}...\n`);
|
|
254
|
+
const results = [];
|
|
255
|
+
for (const p of matrix) {
|
|
256
|
+
if (!opts.embedOnly && p.supportsChat) {
|
|
257
|
+
results.push(await probeChat(p, opts.timeoutMs));
|
|
258
|
+
}
|
|
259
|
+
if (!opts.chatOnly && p.supportsEmbed) {
|
|
260
|
+
results.push(await probeEmbed(p, opts.timeoutMs));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let out;
|
|
265
|
+
if (opts.markdown) out = formatMarkdown(results);
|
|
266
|
+
else if (opts.json) out = formatJson(results);
|
|
267
|
+
else out = formatText(results);
|
|
268
|
+
|
|
269
|
+
process.stdout.write(out + "\n");
|
|
270
|
+
|
|
271
|
+
if (opts.saveMd) {
|
|
272
|
+
fs.mkdirSync(path.dirname(opts.saveMd), { recursive: true });
|
|
273
|
+
fs.writeFileSync(opts.saveMd, formatMarkdown(results));
|
|
274
|
+
process.stderr.write(`Markdown saved to ${opts.saveMd}\n`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
278
|
+
process.exit(failed === 0 ? 0 : 1);
|