mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
983ad4f18b | ||
|
|
cce0110253 | ||
|
|
40391791a1 | ||
|
|
342a0a44f5 | ||
|
|
9494fd6c69 | ||
|
|
5ff30ac9b6 |
@@ -0,0 +1,9 @@
|
||||
# GitHub recognizes this file and shows a "Sponsor" button on the repo page.
|
||||
# Add other platforms here as they get set up. Empty / commented-out entries
|
||||
# are skipped silently.
|
||||
|
||||
buy_me_a_coffee: dtzp555
|
||||
|
||||
# github: [dtzp555-max] # uncomment after GitHub Sponsors enrollment is approved
|
||||
# ko_fi: dtzp555
|
||||
# custom: ["https://example.com/donate"]
|
||||
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## v3.13.0 — 2026-05-07
|
||||
|
||||
### Features (cache layer hardening)
|
||||
|
||||
- **Per-key cache isolation** (D1) — the cache key now includes the API key id, so distinct keys never share cache entries. Anonymous/unauthenticated callers share one `anon` pool. Hash format upgraded to `v2`; legacy v1-format rows orphan and are reaped by the existing TTL cleanup interval (no migration script).
|
||||
- **`cache_control` bypass** (D2) — when a request carries an Anthropic `cache_control` annotation (top-level or nested in a content array), OCP skips its own cache entirely. The caller is using Anthropic-side prompt caching deliberately, and OCP must not interfere. A `cache_skipped{reason: cache_control_present}` log line is emitted on bypass.
|
||||
- **Chunked stream replay** (D3) — when a streaming request hits the cache, the cached content is now emitted as multiple SSE chunks (80 codepoints/chunk, codepoint-safe via `Array.from()`) instead of a single large delta. Multibyte characters (CJK / emoji) stay intact.
|
||||
- **Singleflight stampede protection** (D4) — concurrent identical cache-miss requests now share one upstream `cli.js` spawn instead of spawning N processes. Followers receive byte-identical responses to what the leader returns. All-or-nothing failure semantics: if the leader errors, all followers receive the same error. Streaming-path singleflight is explicitly out of scope (TODO left for follow-up).
|
||||
|
||||
### Behavior changes
|
||||
|
||||
- `/cache/stats` response now includes additive fields `inflight` and `requesters` (current in-flight singleflight entries and total waiting callers). Existing fields `entries`, `totalHits`, `sizeBytes` are preserved unchanged.
|
||||
|
||||
### Governance
|
||||
|
||||
- New ADR [`docs/adr/0005-no-multi-provider.md`](docs/adr/0005-no-multi-provider.md): OCP stays single-provider (Anthropic via `cli.js` spawn). Multi-provider gateway refactor explicitly out of scope; cache improvements are explicitly in scope.
|
||||
- Design spec for this release: [`docs/superpowers/specs/2026-05-07-cache-upgrade-design.md`](docs/superpowers/specs/2026-05-07-cache-upgrade-design.md).
|
||||
|
||||
### No new env vars / no public API surface change
|
||||
|
||||
This release adds no new env vars or endpoints. All four improvements are internal correctness/concurrency upgrades to the existing `CLAUDE_CACHE_TTL`-gated cache layer. No client-observable wire shape change.
|
||||
|
||||
## v3.12.0 — 2026-04-25
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
# OCP — Open Claude Proxy
|
||||
|
||||
[](LICENSE) [](https://github.com/dtzp555-max/ocp/releases) [](https://buymeacoffee.com/dtzp555)
|
||||
|
||||
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
|
||||
|
||||
*Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).*
|
||||
|
||||
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
|
||||
|
||||
```
|
||||
@@ -16,14 +20,15 @@ One proxy. Multiple IDEs. All models. **$0 API cost.**
|
||||
|
||||
## Why OCP?
|
||||
|
||||
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** Concretely:
|
||||
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get:
|
||||
|
||||
- **SSE heartbeat on streaming** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in via `CLAUDE_HEARTBEAT_INTERVAL`). Long Claude reasoning or tool-use pauses can sit silent for minutes; downstream load balancers and reverse proxies often kill the connection at 60s idle. OCP emits an SSE comment frame during silent windows so the connection stays alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
|
||||
- **Alignment constitution + CI guardrail.** [`ALIGNMENT.md`](./ALIGNMENT.md) is the binding spec: every endpoint OCP exposes must correspond to something `cli.js` actually does, with a line-number citation. The [`alignment.yml`](./.github/workflows/alignment.yml) workflow auto-blocks PRs that introduce known-hallucinated tokens (`api/oauth/usage`, `api/usage`, etc). Hard to drift, even with LLM-assisted contributions.
|
||||
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. No more drift between server and installer. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
|
||||
- **Multi anonymous-key distribution** (v3.7.0). Share one OCP instance with friends/family/devices without exposing your OAuth session — each gets a per-user key, with usage tracking and revocation.
|
||||
- **Per-key quota + response cache** (v3.8.0). Daily/weekly/monthly request limits per key. Optional SHA-256 prompt cache for development loops. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
|
||||
- **`ocp-connect` IDE auto-config.** Detects Claude Code, Cursor, Cline, Continue.dev, opencode, and OpenClaw on the client machine and configures them in one command.
|
||||
- **LAN multi-user keys** (v3.7.0) — share one Claude Pro/Max subscription with family, friends, or your own devices. Each user gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation.
|
||||
- **`ocp-connect` one-shot IDE setup** — one command on the client machine detects and configures Claude Code, Cursor, Cline, Continue.dev, OpenCode, and OpenClaw. No pasting `OPENAI_BASE_URL` six times.
|
||||
- **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66))
|
||||
- **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
|
||||
- **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
|
||||
- **`cli.js` alignment + CI guardrail.** LLM-assisted code drifts easily — it's tempting to invent plausible-looking endpoints that `cli.js` doesn't actually use. [`ALIGNMENT.md`](./ALIGNMENT.md) is binding: every endpoint OCP exposes must cite a `cli.js` line. The [`alignment.yml`](./.github/workflows/alignment.yml) CI workflow blocks PRs that introduce known-hallucinated tokens. The payoff is boring: your setup keeps working when `cli.js` ships its next minor.
|
||||
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
|
||||
|
||||
### Comparison
|
||||
|
||||
@@ -472,17 +477,20 @@ ocp settings cacheTTL 300000
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Cache key = SHA-256 of `model` + `messages` + `temperature` + `max_tokens` + `top_p`
|
||||
- Cache key = SHA-256 of `v2|<keyId or "anon">|model + messages + temperature + max_tokens + top_p`
|
||||
- **Per-key isolation** — different API keys never share cache entries; anonymous callers share one `anon` pool
|
||||
- Cache hits return instantly — no Claude CLI process spawned
|
||||
- Works for both streaming and non-streaming requests
|
||||
- **Streaming hits** are replayed as multiple SSE chunks (80 codepoints each), not one large delta — incremental render preserved
|
||||
- **`cache_control` bypass** — if a request carries an Anthropic `cache_control` annotation (top-level or nested in `content[]`), OCP skips its own cache entirely so it doesn't interfere with Anthropic-side prompt caching
|
||||
- **Singleflight stampede protection** — concurrent identical cache-miss requests share one upstream `cli.js` spawn; followers receive byte-identical responses to the leader's call. Non-streaming path only (streaming-path singleflight is a known TODO)
|
||||
- Multi-turn conversations (with `session_id`) are never cached
|
||||
- Expired entries are cleaned up automatically every 10 minutes
|
||||
|
||||
**Management:**
|
||||
```bash
|
||||
# View cache stats
|
||||
# View cache stats (now includes singleflight in-flight counts)
|
||||
curl http://127.0.0.1:3456/cache/stats
|
||||
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000 }
|
||||
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000, "inflight": 0, "requesters": 0 }
|
||||
|
||||
# Clear all cached responses
|
||||
curl -X DELETE http://127.0.0.1:3456/cache
|
||||
@@ -493,6 +501,8 @@ ocp settings cacheTTL 0
|
||||
|
||||
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`.
|
||||
|
||||
**Hash format upgrade in v3.13.0:** legacy `v1` cache rows from earlier versions don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window. No migration script required.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
@@ -701,6 +711,24 @@ OCP runs under a small set of binding documents so contributions stay aligned wi
|
||||
|
||||
If you want to contribute: read `ALIGNMENT.md` first, search `cli.js` for the operation you're proposing, and cite the line number in your PR.
|
||||
|
||||
## Support OCP
|
||||
|
||||
OCP has been **open source from day one** — not a freemium tool, not a commercial product turned open, just open. It will stay that way forever. No paid tiers, no premium features, no "Pro" version locked behind a paywall.
|
||||
|
||||
I built it because my family and I needed it. We use OCP every day across our own machines and IDEs — keeping one Claude Pro/Max subscription powering everything, saving the per-token API cost we'd otherwise pay. It's been quietly heartwarming to hear from users online who say OCP has saved them money the same way it saves ours. That's the whole point.
|
||||
|
||||
Behind every version are hundreds of hours that don't show up in commits: building it from scratch, adding new features as the Claude Code ecosystem evolves, debugging across Mac / Windows / Linux machines, validating against half a dozen IDEs (Claude Code, Cursor, Cline, OpenCode, Aider, Continue.dev, OpenClaw), tracking down `cli.js` drift, OAuth refresh edge cases, SSE streaming quirks, concurrency leaks, and the occasional incident that turns into a multi-day investigation (the [2026-04-11 alignment drift](./docs/adr/0002-alignment-constitution.md), the [v3.11.1 concurrency leak](./CHANGELOG.md), the v3.12 SSE replay regression).
|
||||
|
||||
**The commitment**: this project will keep being updated, keep getting new features, and will stay open source as long as I'm able to maintain it.
|
||||
|
||||
**Please try it.** If something breaks or could be better, [open an issue](https://github.com/dtzp555-max/ocp/issues) — feedback is genuinely what keeps the project moving.
|
||||
|
||||
And if OCP saves you (or your team, or your family) real money and you'd like to chip in toward the next debugging session:
|
||||
|
||||
- ☕ **[Buy me a coffee](https://buymeacoffee.com/dtzp555)**
|
||||
|
||||
Donations directly fund the time it takes to keep OCP saving the community money.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [`LICENSE`](LICENSE).
|
||||
|
||||
@@ -371,6 +371,36 @@ export function getCacheStats() {
|
||||
return { entries: total, totalHits, sizeBytes };
|
||||
}
|
||||
|
||||
// ── Singleflight stampede protection ──
|
||||
|
||||
// In-memory singleflight Map: hash → { promise, requesters }
|
||||
// Deduplicates concurrent identical cache-miss flows so only one upstream call runs.
|
||||
// Per ADR 0005 / spec D4: in-process scope only (single Node process per host).
|
||||
const inflightMap = new Map();
|
||||
|
||||
export function singleflight(hash, fn) {
|
||||
const existing = inflightMap.get(hash);
|
||||
if (existing) {
|
||||
existing.requesters++;
|
||||
return existing.promise;
|
||||
}
|
||||
// Wrap fn() in Promise.resolve().then() so synchronous throws don't escape.
|
||||
const promise = Promise.resolve().then(fn).finally(() => {
|
||||
inflightMap.delete(hash);
|
||||
});
|
||||
inflightMap.set(hash, { promise, requesters: 1 });
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function getInflightStats() {
|
||||
let totalRequesters = 0;
|
||||
for (const entry of inflightMap.values()) totalRequesters += entry.requesters;
|
||||
return {
|
||||
inflight: inflightMap.size,
|
||||
requesters: totalRequesters,
|
||||
};
|
||||
}
|
||||
|
||||
// Find a key by id or name (returns { id, name } or null)
|
||||
export function findKey(idOrName) {
|
||||
const d = getDb();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.12.0",
|
||||
"version": "3.13.0",
|
||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
+39
-7
@@ -34,7 +34,7 @@ import { readFileSync, accessSync, existsSync, constants } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl } from "./keys.mjs";
|
||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||
@@ -590,6 +590,7 @@ function startHeartbeat(res, intervalMs, sessionId) {
|
||||
// ── Call claude CLI (real streaming) ─────────────────────────────────────
|
||||
// Pipes stdout from the claude process directly to SSE chunks as they arrive.
|
||||
// Each data chunk becomes a proper SSE event with delta content in real time.
|
||||
// TODO(cache-singleflight-stream): streaming-path singleflight is out of scope for v3.13.0; see spec D4 streaming caveat.
|
||||
function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
@@ -1265,14 +1266,45 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
const t0Usage = Date.now();
|
||||
const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
|
||||
|
||||
// Non-streaming path with stampede protection: wrap the upstream call in singleflight
|
||||
// when cache is enabled and a hash is present. Concurrent identical requests share
|
||||
// one upstream spawn; followers receive the same promise. Streaming-path dedup is
|
||||
// explicitly out of scope (see TODO comment above callClaudeStreaming).
|
||||
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||
try {
|
||||
const content = await singleflight(req._cacheHash, async () => {
|
||||
// Re-check cache inside the singleflight: a follower that enters before the
|
||||
// leader finishes will wait on the shared promise (not reach here), but a
|
||||
// request that races in just after the previous singleflight cleared the map
|
||||
// will re-read the freshly-populated cache entry here rather than spawning.
|
||||
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
||||
if (recheck) return recheck.response;
|
||||
const c = await callClaude(model, messages, conversationId);
|
||||
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
return c;
|
||||
});
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
completionResponse(res, id, model, content);
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
return;
|
||||
} catch (err) {
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
console.error(`[proxy] error: ${err.message}`);
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) {
|
||||
try { res.end(); } catch {}
|
||||
return;
|
||||
}
|
||||
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
||||
try {
|
||||
const content = await callClaude(model, messages, conversationId);
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
completionResponse(res, id, model, content);
|
||||
// Write to cache
|
||||
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
}
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
} catch (err) {
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
@@ -1551,10 +1583,10 @@ const server = createServer(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// GET /cache/stats — cache statistics
|
||||
// GET /cache/stats — cache statistics (entries, hits, size, inflight singleflight count)
|
||||
if (pathname === "/cache/stats" && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
return jsonResponse(res, 200, getCacheStats());
|
||||
return jsonResponse(res, 200, { ...getCacheStats(), ...getInflightStats() });
|
||||
}
|
||||
|
||||
// DELETE /cache — clear cache
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* 4. Creates start.sh for easy launch
|
||||
* 5. Optionally starts the proxy
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -107,11 +107,18 @@ try {
|
||||
warn("Make sure you're logged in: claude login");
|
||||
}
|
||||
|
||||
// Check openclaw config
|
||||
if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found at ${CONFIG_PATH}`);
|
||||
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
||||
const OPENCLAW_PRESENT = existsSync(CONFIG_PATH);
|
||||
if (OPENCLAW_PRESENT) {
|
||||
log(`OpenClaw config: ${CONFIG_PATH}`);
|
||||
} else {
|
||||
warn(`OpenClaw not detected at ${CONFIG_PATH} — skipping OpenClaw integration.`);
|
||||
warn(`To register OCP with OpenClaw later, install OpenClaw and re-run \`node setup.mjs\`,`);
|
||||
warn(`or run \`ocp update\` if OpenClaw is installed afterward.`);
|
||||
}
|
||||
|
||||
// ── Step 2: Patch openclaw.json ─────────────────────────────────────────
|
||||
if (OPENCLAW_PRESENT) {
|
||||
console.log("\n📝 Configuring OpenClaw...\n");
|
||||
|
||||
const config = readJSON(CONFIG_PATH);
|
||||
@@ -175,8 +182,6 @@ const agentDirs = existsSync(agentsDir)
|
||||
})
|
||||
: [];
|
||||
|
||||
import { readdirSync } from "node:fs";
|
||||
|
||||
for (const agentId of agentDirs) {
|
||||
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
|
||||
try {
|
||||
@@ -208,6 +213,7 @@ for (const agentId of agentDirs) {
|
||||
if (agentDirs.length === 0) {
|
||||
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 4: Create start.sh ─────────────────────────────────────────────
|
||||
console.log("\n🚀 Creating launcher...\n");
|
||||
@@ -238,31 +244,46 @@ if (!DRY_RUN) {
|
||||
log(`Launcher: ${startPath}`);
|
||||
|
||||
// ── Step 5: Summary ─────────────────────────────────────────────────────
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ Setup complete! ║
|
||||
╠══════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ Provider: ${PROVIDER_NAME.padEnd(44)}║
|
||||
║ Port: ${String(PORT).padEnd(44)}║
|
||||
║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}║
|
||||
║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║
|
||||
║ ║
|
||||
║ Start proxy: ║
|
||||
║ bash ${startPath.replace(HOME, "~").padEnd(50)}║
|
||||
║ ║
|
||||
║ Or directly: ║
|
||||
║ node ${serverPath.replace(HOME, "~").padEnd(49)}║
|
||||
║ ║
|
||||
║ Set as default model in openclaw.json: ║
|
||||
║ agents.defaults.model.primary = ║
|
||||
║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║
|
||||
║ ║
|
||||
║ Then restart gateway: ║
|
||||
║ openclaw gateway restart ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
const banner = [
|
||||
`╔══════════════════════════════════════════════════════════════╗`,
|
||||
`║ Setup complete! ║`,
|
||||
`╠══════════════════════════════════════════════════════════════╣`,
|
||||
`║ ║`,
|
||||
`║ Provider: ${PROVIDER_NAME.padEnd(44)}║`,
|
||||
`║ Port: ${String(PORT).padEnd(44)}║`,
|
||||
`║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}║`,
|
||||
`║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║`,
|
||||
`║ ║`,
|
||||
`║ Start proxy: ║`,
|
||||
`║ bash ${startPath.replace(HOME, "~").padEnd(50)}║`,
|
||||
`║ ║`,
|
||||
`║ Or directly: ║`,
|
||||
`║ node ${serverPath.replace(HOME, "~").padEnd(49)}║`,
|
||||
`║ ║`,
|
||||
];
|
||||
if (OPENCLAW_PRESENT) {
|
||||
banner.push(
|
||||
`║ Set as default model in openclaw.json: ║`,
|
||||
`║ agents.defaults.model.primary = ║`,
|
||||
`║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║`,
|
||||
`║ ║`,
|
||||
`║ Then restart gateway: ║`,
|
||||
`║ openclaw gateway restart ║`,
|
||||
`║ ║`,
|
||||
);
|
||||
} else {
|
||||
banner.push(
|
||||
`║ OpenClaw not detected — running in standalone mode. ║`,
|
||||
`║ Point your IDE (Cline / Cursor / Continue / OpenCode / ║`,
|
||||
`║ Aider / OpenClaw) at: ║`,
|
||||
`║ http://${BIND_ADDRESS}:${String(PORT)}/v1${" ".repeat(Math.max(0, 47 - BIND_ADDRESS.length - String(PORT).length))}║`,
|
||||
`║ ║`,
|
||||
`║ See README § "Client Setup" for per-IDE instructions. ║`,
|
||||
`║ ║`,
|
||||
);
|
||||
}
|
||||
banner.push(`╚══════════════════════════════════════════════════════════════╝`);
|
||||
console.log("\n" + banner.join("\n") + "\n");
|
||||
|
||||
// ── Step 6: Optionally start ────────────────────────────────────────────
|
||||
if (!SKIP_START && !DRY_RUN) {
|
||||
|
||||
+101
-1
@@ -3,7 +3,7 @@
|
||||
* Integration test for Quota + Cache features.
|
||||
* Tests database layer functions directly — no server needed.
|
||||
*/
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl } from "./keys.mjs";
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { unlinkSync } from "node:fs";
|
||||
@@ -354,6 +354,106 @@ test("D3: chunked replay uses Array.from — multibyte codepoints stay intact",
|
||||
}
|
||||
});
|
||||
|
||||
// ── PR-B Singleflight tests (async) ──
|
||||
async function asyncTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
passed++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(` ✗ ${name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSingleflightTests() {
|
||||
console.log("\nPR-B Singleflight:");
|
||||
|
||||
// 1. Basic dedup: 10 concurrent calls with same hash execute fn only once.
|
||||
await asyncTest("basic dedup: 10 concurrent callers execute fn only once", async () => {
|
||||
let callCount = 0;
|
||||
const fn = () => new Promise(resolve => {
|
||||
callCount++;
|
||||
setTimeout(() => resolve("result-A"), 20);
|
||||
});
|
||||
const results = await Promise.all(Array.from({ length: 10 }, () => singleflight("sf-dedup-1", fn)));
|
||||
assert.equal(callCount, 1, `fn called ${callCount} times, expected 1`);
|
||||
assert.ok(results.every(r => r === "result-A"), "all 10 callers should receive the same return value");
|
||||
});
|
||||
|
||||
// 2. Failure fan-out: all followers reject when leader rejects.
|
||||
await asyncTest("failure fan-out: all followers reject with leader error", async () => {
|
||||
let callCount = 0;
|
||||
const fn = () => new Promise((_, reject) => {
|
||||
callCount++;
|
||||
setTimeout(() => reject(new Error("upstream-fail")), 20);
|
||||
});
|
||||
const promises = Array.from({ length: 10 }, () => singleflight("sf-fail-1", fn));
|
||||
const results = await Promise.allSettled(promises);
|
||||
assert.equal(callCount, 1, `fn called ${callCount} times, expected 1`);
|
||||
assert.ok(results.every(r => r.status === "rejected"), "all 10 should be rejected");
|
||||
assert.ok(results.every(r => r.reason?.message === "upstream-fail"), "all should share the same error message");
|
||||
});
|
||||
|
||||
// 3a. Map cleanup after success: inflight count returns to 0 after promise resolves.
|
||||
await asyncTest("map cleanup after success: inflight=0 after promise settles", async () => {
|
||||
const fn = () => new Promise(resolve => setTimeout(() => resolve("done"), 10));
|
||||
await singleflight("sf-cleanup-ok", fn);
|
||||
const stats = getInflightStats();
|
||||
assert.equal(stats.inflight, 0, `expected inflight=0 after settlement, got ${stats.inflight}`);
|
||||
});
|
||||
|
||||
// 3b. Map cleanup after failure: inflight count returns to 0 after promise rejects.
|
||||
await asyncTest("map cleanup after failure: inflight=0 after promise rejects", async () => {
|
||||
const fn = () => new Promise((_, reject) => setTimeout(() => reject(new Error("fail")), 10));
|
||||
try { await singleflight("sf-cleanup-fail", fn); } catch {}
|
||||
const stats = getInflightStats();
|
||||
assert.equal(stats.inflight, 0, `expected inflight=0 after rejection, got ${stats.inflight}`);
|
||||
});
|
||||
|
||||
// 4. Different hashes don't share: two parallel calls with distinct hashes both execute.
|
||||
await asyncTest("different hashes do not share a singleflight entry", async () => {
|
||||
let countA = 0;
|
||||
let countB = 0;
|
||||
const fnA = () => new Promise(resolve => { countA++; setTimeout(() => resolve("A"), 20); });
|
||||
const fnB = () => new Promise(resolve => { countB++; setTimeout(() => resolve("B"), 20); });
|
||||
const [rA, rB] = await Promise.all([singleflight("sf-hash-A", fnA), singleflight("sf-hash-B", fnB)]);
|
||||
assert.equal(countA, 1);
|
||||
assert.equal(countB, 1);
|
||||
assert.equal(rA, "A");
|
||||
assert.equal(rB, "B");
|
||||
});
|
||||
|
||||
// 5. getInflightStats shape: returns { inflight: number, requesters: number }.
|
||||
await asyncTest("getInflightStats returns correct shape", async () => {
|
||||
// Verify shape against a settled state (inflight=0 is still the right shape).
|
||||
const stats = getInflightStats();
|
||||
assert.equal(typeof stats.inflight, "number", "inflight should be a number");
|
||||
assert.equal(typeof stats.requesters, "number", "requesters should be a number");
|
||||
// Also verify live counts: start a pending fn, check inflight>0, then resolve.
|
||||
const { promise: blocker, resolve: resolveBlocker } = Promise.withResolvers();
|
||||
const fn = () => blocker;
|
||||
const p = singleflight("sf-stats-shape", fn);
|
||||
const liveStats = getInflightStats();
|
||||
assert.ok(liveStats.inflight >= 1, `expected inflight>=1, got ${liveStats.inflight}`);
|
||||
resolveBlocker("ok");
|
||||
await p;
|
||||
});
|
||||
|
||||
// 6. Sequential calls don't share: singleflight is for concurrent dedup only.
|
||||
await asyncTest("sequential calls with same hash each execute fn independently", async () => {
|
||||
let callCount = 0;
|
||||
const fn = () => new Promise(resolve => { callCount++; setTimeout(() => resolve(callCount), 10); });
|
||||
const r1 = await singleflight("sf-sequential", fn);
|
||||
const r2 = await singleflight("sf-sequential", fn);
|
||||
assert.equal(callCount, 2, `fn should have been called twice, got ${callCount}`);
|
||||
assert.equal(r1, 1);
|
||||
assert.equal(r2, 2);
|
||||
});
|
||||
}
|
||||
|
||||
await runSingleflightTests();
|
||||
|
||||
// ── Cleanup ──
|
||||
closeDb();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user