mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-27 07:55:07 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2fa21ddf7 |
@@ -1,32 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v3.25.0 — 2026-07-27
|
|
||||||
|
|
||||||
Minor release. Headline: **Claude Opus 5** joins the model list and the `opus` alias now resolves to it. Alongside it, two `server.mjs` correctness fixes found by review rather than by users — a monotonic in-flight-counter leak, and cache keys that were hashing the alias string instead of the model it resolves to. The three TUI/prompt fixes that landed after v3.24.0 are included here too.
|
|
||||||
|
|
||||||
Every code PR carried an independent fresh-context reviewer (Iron Rule 10); #192 additionally went through the external codex gate, which is what surfaced the cache-key defect. No new endpoint, no new env var, no new `cli.js` wire behavior.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Claude Opus 5 (`claude-opus-5`) (#192).** New `models.json` entry — `/v1/models` goes 6 → 7 and OpenClaw picks it up on the next `ocp update` (via `scripts/sync-openclaw.mjs`). Verified against the installed CLI rather than assumed: the compiled `claude` 2.1.220 bundle carries `latest_per_family:{fable:"claude-fable-5",opus:"claude-opus-5",sonnet:"claude-sonnet-5",haiku:"claude-haiku-4-5"}`. Availability confirmed with a live `claude -p --model claude-opus-5` completion on the subscription pool. `claude-opus-4-8` is retained for pinning.
|
|
||||||
- `contextWindow` is deliberately **200000**, not Opus 5's native 1M. Two reasons, both verified: (1) `MAX_PROMPT_CHARS` is a **single global** budget — `derivePromptCharBudget` takes `max(contextWindow) × 3` across *all* entries (`lib/prompt.mjs`), so a 1M entry would raise the truncation ceiling to 3,000,000 chars for `claude-haiku-4-5` too, which is genuinely 200k native, converting clean OCP-side truncation into an upstream API rejection; (2) OpenClaw scales its history budget linearly off this value (`contextWindow × maxHistoryShare × SAFETY_MARGIN` = `× 0.6`, plus an oversized-message threshold at `× 0.5`, per `compaction-planning` in OpenClaw 2026.7.1), and its own bundled registry hardcodes 200000 for Claude — the upstream request to raise it to 1M ([openclaw#22979](https://github.com/openclaw/openclaw/issues/22979)) was closed *not planned*. A new regression test pins the invariant so a future 1M entry has to be a deliberate, reviewed change. Raising it for real needs per-model budgets — tracked separately, ADR-level.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **The `opus` alias now resolves to `claude-opus-5` instead of `claude-opus-4-8` (#192).** Every request that names `opus` — and OpenClaw's opus entry — moves to Opus 5 on upgrade. This mirrors what the CLI itself defaults to (`latest_per_family.opus` above), the same reasoning as #168's `sonnet` → `claude-sonnet-5` repoint in v3.23.0. **Pricing is unchanged** ($5/$25 per MTok; CLI registry `pricing:"tier_5_25"` for both Opus 4.8 and Opus 5), so this carries no cost change. Pin `claude-opus-4-8` explicitly to stay on the previous model.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Cache keys hashed the alias, not the model it resolves to (#194).** `model` enters `cacheHash` exactly as the client sent it, so a request for `"opus"` was cached under the literal `"opus"` — and since `models.json` is read once at boot while the SQLite `response_cache` outlives a restart, repointing an alias kept serving the **old model's** answers under it until TTL expiry. That would have silently defeated this release's own `opus` repoint for anyone running with the cache on. All three call sites now hash `Object.hasOwn(MODEL_MAP, model) ? MODEL_MAP[model] : model`, which fixes the normal, structured **and** single-flight keys at once and covers `legacyAliases` for free, with precise invalidation (only the repointed alias's entries change key) rather than a whole-cache flush. (`hasOwn` rather than a bare lookup: `MODEL_MAP` is a plain object, so `MODEL_MAP["__proto__"]` would return a truthy *object* and not even fall through to the `|| model` guard.) **Also closes a latent gap from #177:** the structured and dedup keys never passed `configEpoch` at all, so a `CLAUDE_SYSTEM_PROMPT` change — the original #176 scenario — kept serving structured answers composed under the old config, live since #153. Found by the external codex review of #192.
|
|
||||||
- **In-flight request counter leaked permanently on a pre-spawn throw (#193, reported by @konceptnet in #180).** `stats.activeRequests` was incremented ~40 lines before the child spawn, while its only decrement is reached through that process's events — so any synchronous throw in between (`buildCliArgs`, env assembly, the spawn decision, or `spawn()` itself) leaked `+1` forever, and `/health` and `/status` over-reported in-flight work monotonically. The increment now sits immediately after `activeProcesses.add(proc)`, structurally pairing it with the decrement; no reconciliation pass and no try/catch. Observability-only field, so no admission decision changes.
|
|
||||||
- **TUI: the host `CLAUDE.md` could leak into proxied turns (#187, contributed by @sumlin).** The TUI pane now spawns with `--safe-mode`.
|
|
||||||
- **TUI: `shift+tab to cycle` is accepted as an input-ready marker (#188, contributed by @sumlin).** Claude renders one of two ready-state footers depending on the build — the classic `? for shortcuts` hint, or `shift+tab to cycle` (as part of `⏵⏵ bypass permissions on (shift+tab to cycle)`) on newer 2.1.x. The matcher only knew the classic string, so on those builds it silently reported "never ready": every boot timed out with `tui_pane_not_ready`, and with the warm pool on, every pre-boot failed.
|
|
||||||
- **`OCP_LOCAL_TOOLS` no longer hard-codes a tool list in its wrapper (#191, closes #185).** The positive wrapper claimed a fixed set of tools regardless of `CLAUDE_ALLOWED_TOOLS`, so a narrowed tool surface was described inaccurately to the model.
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- The response-cache and counter fixes ship with **mutation-proven integration tests** built on the existing `ltBoot` child-process fixture (real `server.mjs`, fake `claude` binary, so no quota cost). The counter test reaches a synchronous fault *inside* `spawnClaudeProcess` from environment alone — no production fault hook — by running the child under `--stack-size=200` to lower the spread-throw threshold enough to fit Linux's `MAX_ARG_STRLEN`. Suite: **447 → 457** across this release (per PR: #188 +1, #187 +1, #191 +0, #194 +4, #192 +3, #193 +1).
|
|
||||||
|
|
||||||
## v3.24.0 — 2026-07-21
|
## v3.24.0 — 2026-07-21
|
||||||
|
|
||||||
Minor release. Headline: two long-requested **OpenAI-compat features** land — **multimodal vision** (`image_url` parts) and **structured outputs** (`response_format` / JSON schema). Also: the prompt-char budget now derives from the model SPOT instead of a hand-set constant, an agentic-turn bug that dropped the model's final answer is fixed, and `OCP_LOCAL_TOOLS` supports the OpenClaw-backend use case. Four of the six landed from external contributors (@vvlasy-openclaw). Every code PR carried a fresh-context reviewer (Iron Rule 10); no new endpoint, no new `cli.js` wire behavior.
|
Minor release. Headline: two long-requested **OpenAI-compat features** land — **multimodal vision** (`image_url` parts) and **structured outputs** (`response_format` / JSON schema). Also: the prompt-char budget now derives from the model SPOT instead of a hand-set constant, an agentic-turn bug that dropped the model's final answer is fixed, and `OCP_LOCAL_TOOLS` supports the OpenClaw-backend use case. Four of the six landed from external contributors (@vvlasy-openclaw). Every code PR carried a fresh-context reviewer (Iron Rule 10); no new endpoint, no new `cli.js` wire behavior.
|
||||||
|
|||||||
@@ -113,11 +113,11 @@ node setup.mjs
|
|||||||
|
|
||||||
`setup.mjs` verifies the Claude CLI, starts the proxy on port 3456, and installs auto-start (launchd on macOS, systemd on Linux). The `ocp` CLI lands at `~/ocp/ocp` — symlink it onto your PATH (`sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp`, or `ln -sf ~/ocp/ocp ~/.local/bin/ocp`) or alias it (`alias ocp=~/ocp/ocp`); the rest of the docs assume `ocp` is on your PATH.
|
`setup.mjs` verifies the Claude CLI, starts the proxy on port 3456, and installs auto-start (launchd on macOS, systemd on Linux). The `ocp` CLI lands at `~/ocp/ocp` — symlink it onto your PATH (`sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp`, or `ln -sf ~/ocp/ocp ~/.local/bin/ocp`) or alias it (`alias ocp=~/ocp/ocp`); the rest of the docs assume `ocp` is on your PATH.
|
||||||
|
|
||||||
**Verify** — should list 7 models:
|
**Verify** — should list 6 models:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:3456/v1/models
|
curl http://127.0.0.1:3456/v1/models
|
||||||
# claude-opus-5, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
# claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||||
```
|
```
|
||||||
|
|
||||||
**Connect one IDE** — point any OpenAI-compatible tool at the proxy, then reload your shell and start a tool (Cline / Continue / Cursor / OpenCode):
|
**Connect one IDE** — point any OpenAI-compatible tool at the proxy, then reload your shell and start a tool (Cline / Continue / Cursor / OpenCode):
|
||||||
@@ -169,9 +169,8 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t
|
|||||||
|
|
||||||
| Model ID | Notes |
|
| Model ID | Notes |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| `claude-opus-5` | Most capable (default for `opus` alias) |
|
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
|
||||||
| `claude-opus-4-8` | Previous Opus, retained for pinning |
|
| `claude-opus-4-7` | Previous Opus, retained for pinning |
|
||||||
| `claude-opus-4-7` | Older Opus, retained for pinning |
|
|
||||||
| `claude-opus-4-6` | Older Opus, retained for pinning |
|
| `claude-opus-4-6` | Older Opus, retained for pinning |
|
||||||
| `claude-sonnet-5` | Latest Sonnet (default for `sonnet` alias) |
|
| `claude-sonnet-5` | Latest Sonnet (default for `sonnet` alias) |
|
||||||
| `claude-sonnet-4-6` | Previous Sonnet, retained for pinning |
|
| `claude-sonnet-4-6` | Previous Sonnet, retained for pinning |
|
||||||
@@ -388,7 +387,7 @@ curl -X DELETE http://127.0.0.1:3456/cache # clear all cached responses
|
|||||||
ocp settings cacheTTL 0 # disable at runtime
|
ocp settings cacheTTL 0 # disable at runtime
|
||||||
```
|
```
|
||||||
|
|
||||||
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`. **Cache keys resolve model aliases as of v3.25.0:** a request for an alias (`opus`, `sonnet`, `haiku`, or a legacy alias like `claude-haiku-4-5`) is now keyed on the canonical model it resolves to, not on the string the client sent. Two consequences, both one-time and self-healing: rows written before the upgrade don't match the new lookups, so they orphan and are reaped by the TTL cleanup interval within one window — no migration script required; and an alias now correctly shares a cache slot with its canonical id, since both produce an identical spawn. Scope: for the **normal** cache only alias-addressed rows rekey — rows keyed on a literal model id keep matching, *unless* you run `OCP_LOCAL_TOOLS=1`, in which case the whole normal cache rekeys once because v3.25.0 also reworded that wrapper and the wrapper text feeds the config epoch. **Every structured-output row rekeys regardless**, since the same change folds the config epoch into the structured key, which it previously omitted. This is what makes an alias repoint (such as v3.25.0's `opus` → `claude-opus-5`) take effect immediately instead of being masked by the cache until TTL expiry. **Hash format upgrade in v3.13.0:** legacy `v1` cache rows don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window — no migration script required.
|
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 don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window — no migration script required.
|
||||||
|
|
||||||
## Structured Outputs (OpenAI `response_format`)
|
## Structured Outputs (OpenAI `response_format`)
|
||||||
|
|
||||||
@@ -541,7 +540,6 @@ The simplest path: ask your AI — paste `Run `ocp doctor` and follow its `next_
|
|||||||
|
|
||||||
- **A TUI session vanished right after upgrading OCP** — if a pre-3.21.1 and a post-3.21.1 instance ran on the same host at the same time during an upgrade, the new instance's one-time boot reap can, once, kill an old-format (`ocp-tui-<8hex>`) live TUI session belonging to the still-running old instance. Restart the affected session (`ocp restart` or re-run your TUI turn) and it returns under the new instance's port-scoped naming.
|
- **A TUI session vanished right after upgrading OCP** — if a pre-3.21.1 and a post-3.21.1 instance ran on the same host at the same time during an upgrade, the new instance's one-time boot reap can, once, kill an old-format (`ocp-tui-<8hex>`) live TUI session belonging to the still-running old instance. Restart the affected session (`ocp restart` or re-run your TUI turn) and it returns under the new instance's port-scoped naming.
|
||||||
- **OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)** — the running shell had the old `cmd_update` cached, so the sync hook doesn't fire on that single jump. Run once: `node ~/ocp/scripts/sync-openclaw.mjs && openclaw gateway restart`. Every future update syncs automatically.
|
- **OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)** — the running shell had the old `cmd_update` cached, so the sync hook doesn't fire on that single jump. Run once: `node ~/ocp/scripts/sync-openclaw.mjs && openclaw gateway restart`. Every future update syncs automatically.
|
||||||
- **Response-cache hit rate drops once after upgrading to v3.25.0** — only if you run with the cache on (`CLAUDE_CACHE_TTL > 0`; it is off by default). v3.25.0 keys the cache on the resolved model instead of the string the client sent, so alias-addressed rows (and *all* structured-output rows) orphan and are reaped by the TTL cleanup within one window. **No action required.** Details: [docs/troubleshooting.md#cache-rekey-v3250](docs/troubleshooting.md#cache-rekey-v3250).
|
|
||||||
|
|
||||||
Full manual — setup failures, env-var-not-taking-effect-after-restart (launchd bootout+bootstrap vs `kickstart -k`), stuck sessions, "OpenClaw registry out of sync", and the two-layer TUI-mode 401 root cause + fix: **[docs/troubleshooting.md](docs/troubleshooting.md)**.
|
Full manual — setup failures, env-var-not-taking-effect-after-restart (launchd bootout+bootstrap vs `kickstart -k`), stuck sessions, "OpenClaw registry out of sync", and the two-layer TUI-mode 401 root cause + fix: **[docs/troubleshooting.md](docs/troubleshooting.md)**.
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -78,7 +78,7 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
|
|||||||
**Verify:**
|
**Verify:**
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:3456/v1/models
|
curl http://127.0.0.1:3456/v1/models
|
||||||
# Returns: claude-opus-5, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||||
```
|
```
|
||||||
|
|
||||||
### Headless install notes
|
### Headless install notes
|
||||||
@@ -114,7 +114,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
|||||||
installed and logged in (`claude auth status`). Install missing pieces
|
installed and logged in (`claude auth status`). Install missing pieces
|
||||||
using my system's package manager.
|
using my system's package manager.
|
||||||
2. git clone the repo, cd in, and run `node setup.mjs`.
|
2. git clone the repo, cd in, and run `node setup.mjs`.
|
||||||
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 7 models).
|
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 6 models).
|
||||||
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
|
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
|
||||||
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
|
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
|
|||||||
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
|
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
|
||||||
6. Run `ocp lan` to show me the LAN IP and connect command.
|
6. Run `ocp lan` to show me the LAN IP and connect command.
|
||||||
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
|
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
|
||||||
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 7 models.
|
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 6 models.
|
||||||
|
|
||||||
Tell me each step before running it. On error, diagnose before retrying.
|
Tell me each step before running it. On error, diagnose before retrying.
|
||||||
```
|
```
|
||||||
@@ -163,7 +163,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
|
|||||||
chmod +x ocp-connect
|
chmod +x ocp-connect
|
||||||
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
||||||
3. Follow any IDE-specific manual hints it prints.
|
3. Follow any IDE-specific manual hints it prints.
|
||||||
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 7 models.
|
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 6 models.
|
||||||
5. Tell me to reload my shell + restart any IDE that was already running.
|
5. Tell me to reload my shell + restart any IDE that was already running.
|
||||||
|
|
||||||
Don't auto-retry on error. Tell me the failure mode first.
|
Don't auto-retry on error. Tell me the failure mode first.
|
||||||
@@ -216,7 +216,7 @@ OCP Connect v1.3.0
|
|||||||
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||||
|
|
||||||
Testing API access...
|
Testing API access...
|
||||||
✓ API accessible (7 models available)
|
✓ API accessible (6 models available)
|
||||||
|
|
||||||
Shell config:
|
Shell config:
|
||||||
✓ .bashrc
|
✓ .bashrc
|
||||||
@@ -246,7 +246,6 @@ OCP Connect v1.3.0
|
|||||||
✓ OpenClaw configured
|
✓ OpenClaw configured
|
||||||
Provider: ocp
|
Provider: ocp
|
||||||
Models:
|
Models:
|
||||||
• ocp/claude-opus-5
|
|
||||||
• ocp/claude-opus-4-8
|
• ocp/claude-opus-4-8
|
||||||
• ocp/claude-opus-4-7
|
• ocp/claude-opus-4-7
|
||||||
• ocp/claude-opus-4-6
|
• ocp/claude-opus-4-6
|
||||||
|
|||||||
@@ -116,22 +116,6 @@ openclaw gateway restart # so OpenClaw re-reads the config
|
|||||||
|
|
||||||
Future `ocp update` invocations sync automatically.
|
Future `ocp update` invocations sync automatically.
|
||||||
|
|
||||||
<a id="cache-rekey-v3250"></a>
|
|
||||||
### Response-cache hit rate drops once after upgrading to v3.25.0
|
|
||||||
|
|
||||||
Only affects instances running with the response cache **on** (`CLAUDE_CACHE_TTL > 0`); it is off by default, so most installs see nothing.
|
|
||||||
|
|
||||||
v3.25.0 keys the cache on the **resolved** model rather than on the string the client sent, so rows written for an alias (`opus`, `sonnet`, `haiku`, or a legacy alias like `claude-haiku-4-5`) no longer match. Those rows orphan and are reaped by the TTL cleanup interval within one window — **no migration script, no action required**; expect one window of extra misses and then normal hit rates.
|
|
||||||
|
|
||||||
Two different scopes, worth being precise about:
|
|
||||||
|
|
||||||
- **Normal cache** — only *alias-addressed* rows rekey. Rows written under a literal model id (`claude-sonnet-5`) keep matching — **unless the instance runs `OCP_LOCAL_TOOLS=1`**, in which case the entire normal cache rekeys once. That is a separate mechanism: v3.25.0 also reworded the local-tools wrapper, and the wrapper text is one of the four inputs to `CONFIG_EPOCH`, which every normal cache key folds in (established behavior since v3.23.0, not new here).
|
|
||||||
- **Structured-output cache** — **every** row rekeys, alias or literal. The same change also folds the config epoch into the structured key, which it had never included; that gap meant a `CLAUDE_SYSTEM_PROMPT` change did not invalidate structured answers either. Structured caching only exists from v3.24.0, so there is at most one release worth of rows to orphan.
|
|
||||||
|
|
||||||
This is deliberate, and it is what makes an alias repoint take effect. Before v3.25.0, changing where an alias pointed (v3.25.0 itself repoints `opus` → `claude-opus-5`) left the cache serving the **old** model's answers under that alias until TTL expiry, because `models.json` is read once at boot while the SQLite cache survives the restart. If you were running with the cache on and repointed an alias in an earlier version, that is why it appeared not to take.
|
|
||||||
|
|
||||||
A side effect worth knowing: an alias and its canonical id now **share** a cache slot, since both produce an identical spawn. That is a small hit-rate improvement in steady state.
|
|
||||||
|
|
||||||
<a id="tui-401"></a>
|
<a id="tui-401"></a>
|
||||||
### TUI-mode returns a permanent `Please run /login` 401 (re-login doesn't stick)
|
### TUI-mode returns a permanent `Please run /login` 401 (re-login doesn't stick)
|
||||||
|
|
||||||
|
|||||||
+1
-9
@@ -2,14 +2,6 @@
|
|||||||
"$schema": "./models.schema.json",
|
"$schema": "./models.schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"models": [
|
"models": [
|
||||||
{
|
|
||||||
"id": "claude-opus-5",
|
|
||||||
"displayName": "Claude Opus 5",
|
|
||||||
"openclawName": "Claude Opus 5 (via CLI)",
|
|
||||||
"reasoning": true,
|
|
||||||
"contextWindow": 200000,
|
|
||||||
"maxTokens": 16384
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "claude-opus-4-8",
|
"id": "claude-opus-4-8",
|
||||||
"displayName": "Claude Opus 4.8",
|
"displayName": "Claude Opus 4.8",
|
||||||
@@ -60,7 +52,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"opus": "claude-opus-5",
|
"opus": "claude-opus-4-8",
|
||||||
"sonnet": "claude-sonnet-5",
|
"sonnet": "claude-sonnet-5",
|
||||||
"haiku": "claude-haiku-4-5-20251001"
|
"haiku": "claude-haiku-4-5-20251001"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.25.0",
|
"version": "3.24.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.",
|
"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",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+4
-28
@@ -1371,6 +1371,7 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
|
|||||||
promptChars = stdinPayload.length;
|
promptChars = stdinPayload.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stats.activeRequests++;
|
||||||
stats.totalRequests++;
|
stats.totalRequests++;
|
||||||
stats.oneOffRequests++;
|
stats.oneOffRequests++;
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
@@ -1413,17 +1414,6 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
|
|||||||
|
|
||||||
const proc = spawn(CLAUDE, cliArgs, spawnOpts);
|
const proc = spawn(CLAUDE, cliArgs, spawnOpts);
|
||||||
activeProcesses.add(proc);
|
activeProcesses.add(proc);
|
||||||
// Counter drift (#180, reported by @konceptnet): increment ONLY after the spawn has
|
|
||||||
// succeeded and the process is registered. Incrementing before the spawn (as this did) leaked
|
|
||||||
// +1 permanently on any synchronous throw in between — buildCliArgs, env assembly, the spawn
|
|
||||||
// decision, or spawn() itself — because nothing was yet attached that could undo it.
|
|
||||||
//
|
|
||||||
// cleanup() is the SOLE decrement site, but note how it is reached: only 'exit' is wired HERE
|
|
||||||
// (below); 'close' and 'error' are wired by each CALLER (callClaude / callClaudeStreaming).
|
|
||||||
// That caller wiring is REQUIRED, not belt-and-braces — a FAILED spawn emits 'error' and
|
|
||||||
// 'close' but never 'exit', so without it a spawn failure would never decrement. A future
|
|
||||||
// third caller of spawnClaudeProcess must wire them too.
|
|
||||||
stats.activeRequests++;
|
|
||||||
|
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
let gotFirstByte = false;
|
let gotFirstByte = false;
|
||||||
@@ -2814,20 +2804,6 @@ async function handleChatCompletions(req, res) {
|
|||||||
|
|
||||||
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
|
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
|
||||||
const model = parsed.model || modelsConfig.aliases.sonnet;
|
const model = parsed.model || modelsConfig.aliases.sonnet;
|
||||||
// Cache keys must hash the RESOLVED model, never the string the client happened to use.
|
|
||||||
// `model` is whatever was sent — a canonical id, an alias ("opus"), or a legacyAlias
|
|
||||||
// ("claude-opus-4"). MODEL_MAP carries all three, and models.json is read once at boot, so
|
|
||||||
// repointing an alias only takes effect on restart — while the SQLite response_cache outlives
|
|
||||||
// it. Hashing the raw string would therefore keep serving the OLD model's answers under that
|
|
||||||
// alias until TTL expiry, silently defeating the repoint (the #176 hazard, for aliases).
|
|
||||||
// Resolving first also means "opus" and "claude-opus-5" correctly share one slot: identical
|
|
||||||
// spawn, identical answer. Only the cache KEY is resolved — `model` is still echoed back to
|
|
||||||
// the client verbatim, so the wire response is unchanged.
|
|
||||||
// hasOwn, not a bare lookup: MODEL_MAP is a plain object, so `MODEL_MAP["constructor"]`
|
|
||||||
// would return an inherited FUNCTION. Unreachable today (the VALID_MODELS gate below 400s
|
|
||||||
// first, and it is built from Object.keys so it holds only own keys), but a bare lookup
|
|
||||||
// would hand cacheHash a function the day anyone widens that gate or moves this binding.
|
|
||||||
const cacheModel = Object.hasOwn(MODEL_MAP, model) ? MODEL_MAP[model] : model;
|
|
||||||
const stream = parsed.stream;
|
const stream = parsed.stream;
|
||||||
|
|
||||||
// Validate model against known models
|
// Validate model against known models
|
||||||
@@ -2924,7 +2900,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
||||||
let structuredHash = null;
|
let structuredHash = null;
|
||||||
if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) {
|
if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) {
|
||||||
structuredHash = cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured, configEpoch: CONFIG_EPOCH });
|
structuredHash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured });
|
||||||
try {
|
try {
|
||||||
const cached = getCachedResponse(structuredHash, CACHE_TTL);
|
const cached = getCachedResponse(structuredHash, CACHE_TTL);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -2943,7 +2919,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
// one-off structured request (not stateful sessions / client-side prompt caching), independent of
|
// one-off structured request (not stateful sessions / client-side prompt caching), independent of
|
||||||
// whether OCP response caching is enabled; when caching IS on, the same key gates cache read/write.
|
// whether OCP response caching is enabled; when caching IS on, the same key gates cache read/write.
|
||||||
const dedupKey = (!conversationId && !hasCacheControl(messages))
|
const dedupKey = (!conversationId && !hasCacheControl(messages))
|
||||||
? cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured, configEpoch: CONFIG_EPOCH })
|
? cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured })
|
||||||
: null;
|
: null;
|
||||||
const runStructured = async () => {
|
const runStructured = async () => {
|
||||||
const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured);
|
const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured);
|
||||||
@@ -2992,7 +2968,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
} else {
|
} else {
|
||||||
// D1: include keyId in hash to isolate per-key cache pools (v2 format).
|
// D1: include keyId in hash to isolate per-key cache pools (v2 format).
|
||||||
// configEpoch (#176): any boot-config change that shapes answers invalidates the cache.
|
// configEpoch (#176): any boot-config change that shapes answers invalidates the cache.
|
||||||
const hash = cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH });
|
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH });
|
||||||
req._cacheHash = hash; // store for later write-back
|
req._cacheHash = hash; // store for later write-back
|
||||||
try {
|
try {
|
||||||
const cached = getCachedResponse(hash, CACHE_TTL);
|
const cached = getCachedResponse(hash, CACHE_TTL);
|
||||||
|
|||||||
+3
-216
@@ -959,8 +959,7 @@ test("localToolsSafetyError: multi is checked before loopback/anon (most severe
|
|||||||
// a request — and boot-gate refusals are asserted by the process exit code. Without these, the
|
// a request — and boot-gate refusals are asserted by the process exit code. Without these, the
|
||||||
// wiring (extractSystemPrompt using SYSTEM_PROMPT_WRAPPER, the boot gate, the epoch fold) can be
|
// wiring (extractSystemPrompt using SYSTEM_PROMPT_WRAPPER, the boot gate, the epoch fold) can be
|
||||||
// silently reverted with the unit suite still green — the maintainer's #1 rejection pattern.
|
// silently reverted with the unit suite still green — the maintainer's #1 rejection pattern.
|
||||||
import { spawn as _ltSpawn, execFileSync as _ltExecFile } from "node:child_process";
|
import { spawn as _ltSpawn } from "node:child_process";
|
||||||
import { createServer as _ltNetServer } from "node:net";
|
|
||||||
import { writeFileSync as _ltWrite, chmodSync as _ltChmod, readFileSync as _ltRead, existsSync as _ltExists, rmSync as _ltRm, mkdtempSync as _ltMkdtemp } from "node:fs";
|
import { writeFileSync as _ltWrite, chmodSync as _ltChmod, readFileSync as _ltRead, existsSync as _ltExists, rmSync as _ltRm, mkdtempSync as _ltMkdtemp } from "node:fs";
|
||||||
import { tmpdir as _ltTmp } from "node:os";
|
import { tmpdir as _ltTmp } from "node:os";
|
||||||
import { fileURLToPath as _ltF2P } from "node:url";
|
import { fileURLToPath as _ltF2P } from "node:url";
|
||||||
@@ -985,8 +984,8 @@ exit 0
|
|||||||
|
|
||||||
function ltMkdir() { return _ltMkdtemp(join(_ltTmp(), "ocp-lt-")); }
|
function ltMkdir() { return _ltMkdtemp(join(_ltTmp(), "ocp-lt-")); }
|
||||||
function ltFake(dir) { const p = join(dir, "claude"); _ltWrite(p, LT_FAKE); _ltChmod(p, 0o755); return p; }
|
function ltFake(dir) { const p = join(dir, "claude"); _ltWrite(p, LT_FAKE); _ltChmod(p, 0o755); return p; }
|
||||||
function ltBoot(env, dir, nodeArgs = []) {
|
function ltBoot(env, dir) {
|
||||||
const child = _ltSpawn(process.execPath, [...nodeArgs, LT_SERVER], {
|
const child = _ltSpawn(process.execPath, [LT_SERVER], {
|
||||||
env: { ...process.env, NODE_ENV: "test", OCP_DIR_OVERRIDE: dir, OCP_SKIP_AUTH_TEST: "1",
|
env: { ...process.env, NODE_ENV: "test", OCP_DIR_OVERRIDE: dir, OCP_SKIP_AUTH_TEST: "1",
|
||||||
CLAUDE_BIND: "127.0.0.1", CLAUDE_AUTH_MODE: "none", CLAUDE_CACHE_TTL: "0", CLAUDE_TIMEOUT: "4000", ...env },
|
CLAUDE_BIND: "127.0.0.1", CLAUDE_AUTH_MODE: "none", CLAUDE_CACHE_TTL: "0", CLAUDE_TIMEOUT: "4000", ...env },
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
@@ -1105,195 +1104,6 @@ test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response ca
|
|||||||
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── active-request counter is paired to the process lifecycle (#180 / #193) ──
|
|
||||||
// The counter used to be incremented ~40 lines before the spawn, while its only decrement
|
|
||||||
// (cleanup()) is wired to that proc's events — so any SYNCHRONOUS throw in between leaked +1
|
|
||||||
// permanently. Driving that fault needs no production hook and no test double: buildCliArgs
|
|
||||||
// does `args.push("--allowedTools", ...ALLOWED_TOOLS)`, and a spread of enough elements throws
|
|
||||||
// RangeError synchronously, right inside the window.
|
|
||||||
//
|
|
||||||
// Getting there on Linux needs one more turn of the screw. The spread's cost is per ELEMENT,
|
|
||||||
// so the naive form needs ~124k elements ≈ 250KB in one env var — and Linux caps a single env
|
|
||||||
// string at MAX_ARG_STRLEN (32 * PAGE_SIZE = 131072 on x86-64), so execve rejects it (E2BIG).
|
|
||||||
// Encoding around it fails too: empty items are 1 byte each, but `.filter(Boolean)`
|
|
||||||
// (server.mjs:355) strips them, so ALLOWED_TOOLS ends up empty and the spread branch is never
|
|
||||||
// entered at all.
|
|
||||||
//
|
|
||||||
// The lever is the stack: the throw threshold scales with it, and ltBoot spawns the server, so
|
|
||||||
// the test owns its argv. Running the child under --stack-size=200 drops the threshold ~5x
|
|
||||||
// (~24k elements ≈ 48KB), which fits Linux's limit with room to spare.
|
|
||||||
//
|
|
||||||
// The threshold is DISCOVERED, in a child under the SAME --stack-size (measuring it in this
|
|
||||||
// process would report the parent's stack, which is not the one that matters), then taken with
|
|
||||||
// 1.5x margin and hard-asserted under MAX_ARG_STRLEN. A hard-coded count would silently stop
|
|
||||||
// triggering on another machine and the test would pass vacuously.
|
|
||||||
const LT_STACK = 200; // child V8 stack (KB); lowers the spread-throw threshold
|
|
||||||
const LT_MAX_ARG_STRLEN = 131072; // Linux, x86-64: 32 * 4096
|
|
||||||
function ltSpreadThrowCount(stackKb) {
|
|
||||||
// Binary-search the smallest element count whose spread throws, inside a child running with
|
|
||||||
// the stack the server will actually use.
|
|
||||||
const src = `const t=n=>{try{const a=[];a.push("--allowedTools",...Array(n).fill("x"));return false}catch{return true}};` +
|
|
||||||
`let lo=500,hi=400000;if(!t(hi)){console.log(0)}else{while(lo<hi){const m=(lo+hi)>>1;t(m)?hi=m:lo=m+1}console.log(lo)}`;
|
|
||||||
try {
|
|
||||||
return Number(String(_ltExecFile(process.execPath, [`--stack-size=${stackKb}`, "-e", src], { encoding: "utf8" })).trim()) || 0;
|
|
||||||
} catch { return 0; }
|
|
||||||
}
|
|
||||||
async function ltFreePort() {
|
|
||||||
const srv = _ltNetServer();
|
|
||||||
await new Promise(r => srv.listen(0, "127.0.0.1", r));
|
|
||||||
const p = srv.address().port;
|
|
||||||
await new Promise(r => srv.close(r));
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
async function ltPostStatus(port, body) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
return { status: r.status, text: await r.text() };
|
|
||||||
} catch { return { status: 0, text: "" }; }
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\nactive-request counter pairing (#180 / #193):");
|
|
||||||
|
|
||||||
test("integration: a synchronous pre-spawn throw must not leak stats.activeRequests", async () => {
|
|
||||||
if (!LT_POSIX) return;
|
|
||||||
const thr = ltSpreadThrowCount(LT_STACK);
|
|
||||||
assert.ok(thr > 0, `no spread-throw threshold found under --stack-size=${LT_STACK}`);
|
|
||||||
const n = Math.ceil(thr * 1.5); // margin over the measured threshold
|
|
||||||
const entry = Array(n).fill("x").join(",");
|
|
||||||
const bytes = Buffer.byteLength(entry);
|
|
||||||
// Hard gate: if this ever stops fitting, fail loudly rather than regress to an E2BIG skip.
|
|
||||||
assert.ok(bytes <= LT_MAX_ARG_STRLEN,
|
|
||||||
`env entry ${bytes}B exceeds MAX_ARG_STRLEN ${LT_MAX_ARG_STRLEN}B — lower LT_STACK`);
|
|
||||||
const port = await ltFreePort();
|
|
||||||
const dir = ltMkdir(); const fake = ltFake(dir);
|
|
||||||
const { child, buf } = ltBoot({
|
|
||||||
CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_ALLOWED_TOOLS: entry,
|
|
||||||
}, dir, [`--stack-size=${LT_STACK}`]);
|
|
||||||
let spawnErr = null;
|
|
||||||
child.on("error", e => { spawnErr = e; });
|
|
||||||
try {
|
|
||||||
const up = await ltWait(() => buf.out.includes("listening on") || spawnErr, 20000);
|
|
||||||
assert.ok(up && !spawnErr, `did not start: ${spawnErr ? spawnErr.message : buf.err.slice(0, 300)}`);
|
|
||||||
const req = { model: "haiku", messages: [{ role: "user", content: "leak-probe" }] };
|
|
||||||
const res = [];
|
|
||||||
for (let i = 0; i < 3; i++) res.push(await ltPostStatus(port, req));
|
|
||||||
// Non-vacuous on two axes: the requests must actually fail, AND the failure must be the
|
|
||||||
// stack overflow from the --allowedTools spread — not some unrelated 500 that a small
|
|
||||||
// stack happened to produce. Without the second check a different fault would still leave
|
|
||||||
// the counter at 0 and the test would "pass" for the wrong reason.
|
|
||||||
assert.deepEqual(res.map(r => r.status), [500, 500, 500],
|
|
||||||
`expected the pre-spawn throw to surface as 500s, got ${res.map(r => r.status)}`);
|
|
||||||
assert.ok(res.every(r => /call stack size exceeded/i.test(r.text)),
|
|
||||||
`500s must come from the spread's RangeError; got: ${res[0].text.slice(0, 200)}`);
|
|
||||||
const r = await fetch(`http://127.0.0.1:${port}/status`);
|
|
||||||
const active = (await r.json()).requests.active;
|
|
||||||
assert.equal(active, 0,
|
|
||||||
`3 requests threw before their spawn; the counter must be back to 0, got ${active} (this is the #180 leak)`);
|
|
||||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Cache keys hash the RESOLVED model, not the alias string (#194) ──────────
|
|
||||||
// models.json is read once at boot, so repointing an alias only takes effect on restart —
|
|
||||||
// while the SQLite response_cache outlives it. Hashing the raw string would keep serving the
|
|
||||||
// OLD model's answers under that alias until TTL expiry. Rather than mutate models.json
|
|
||||||
// mid-suite, these assert the equivalent observable: an alias and its canonical target must
|
|
||||||
// land on the SAME cache slot, which is true only if the key is resolved before hashing.
|
|
||||||
// Mutation: change `cacheModel` back to `model` at the three cacheHash call sites in
|
|
||||||
// server.mjs and both tests go red (2 spawns instead of 1).
|
|
||||||
|
|
||||||
// Fake that emits schema-valid JSON, so the structured path caches a VALIDATED result
|
|
||||||
// (the stock LT_FAKE returns "OK", which fails validation → refusal → never cached).
|
|
||||||
const LT_FAKE_JSON = `#!/bin/sh
|
|
||||||
if [ -n "$SP_COUNTER" ]; then c=$(cat "$SP_COUNTER" 2>/dev/null || echo 0); echo $((c+1)) > "$SP_COUNTER"; fi
|
|
||||||
printf '%s\\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"{\\"ok\\":true}"}]}}'
|
|
||||||
printf '%s\\n' '{"type":"result"}'
|
|
||||||
exit 0
|
|
||||||
`;
|
|
||||||
function ltFakeJson(dir) { const p = join(dir, "claude-json"); _ltWrite(p, LT_FAKE_JSON); _ltChmod(p, 0o755); return p; }
|
|
||||||
const LT_SCHEMA = { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false };
|
|
||||||
|
|
||||||
console.log("\nCache key resolves the model alias (#194):");
|
|
||||||
|
|
||||||
test("integration: an alias and its canonical target share ONE cache slot (normal path)", async () => {
|
|
||||||
if (!LT_POSIX) return;
|
|
||||||
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
|
|
||||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39360", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
|
||||||
try {
|
|
||||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
|
||||||
_ltWrite(counter, "0");
|
|
||||||
const msgs = [{ role: "user", content: "alias-resolution-probe" }];
|
|
||||||
await ltPost(39360, { model: "sonnet", messages: msgs }); // miss → spawn
|
|
||||||
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000);
|
|
||||||
await ltPost(39360, { model: "claude-sonnet-5", messages: msgs }); // same resolved model → HIT
|
|
||||||
await new Promise(r => setTimeout(r, 600));
|
|
||||||
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
|
|
||||||
"the canonical id must hit the slot the alias populated — a 2nd spawn means the key still hashes the raw alias");
|
|
||||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test("integration: an alias and its canonical target share ONE cache slot (STRUCTURED path)", async () => {
|
|
||||||
if (!LT_POSIX) return;
|
|
||||||
const dir = ltMkdir(); const fake = ltFakeJson(dir); const counter = join(dir, "spawns.txt");
|
|
||||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39361", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
|
||||||
try {
|
|
||||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
|
||||||
_ltWrite(counter, "0");
|
|
||||||
const rf = { type: "json_schema", json_schema: { name: "probe", schema: LT_SCHEMA } };
|
|
||||||
const msgs = [{ role: "user", content: "structured-alias-probe" }];
|
|
||||||
await ltPost(39361, { model: "sonnet", messages: msgs, response_format: rf });
|
|
||||||
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 4000);
|
|
||||||
await ltPost(39361, { model: "claude-sonnet-5", messages: msgs, response_format: rf });
|
|
||||||
await new Promise(r => setTimeout(r, 600));
|
|
||||||
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
|
|
||||||
"structured cache key must resolve the alias too — this is the path the epoch-only fix missed");
|
|
||||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// MODEL_MAP is models[] + aliases + legacyAliases, so resolving covers legacyAliases for free.
|
|
||||||
// The three tests above all use `sonnet` (a plain alias); this pins the legacyAlias leg explicitly
|
|
||||||
// rather than leaving it covered only by construction.
|
|
||||||
test("integration: a legacyAlias shares ONE cache slot with its canonical target", async () => {
|
|
||||||
if (!LT_POSIX) return;
|
|
||||||
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
|
|
||||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39364", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
|
|
||||||
try {
|
|
||||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
|
||||||
_ltWrite(counter, "0");
|
|
||||||
const msgs = [{ role: "user", content: "legacy-alias-probe" }];
|
|
||||||
await ltPost(39364, { model: "claude-haiku-4-5", messages: msgs }); // legacyAlias
|
|
||||||
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000);
|
|
||||||
await ltPost(39364, { model: "claude-haiku-4-5-20251001", messages: msgs }); // canonical
|
|
||||||
await new Promise(r => setTimeout(r, 600));
|
|
||||||
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
|
|
||||||
"legacyAliases live in MODEL_MAP too — resolving must collapse them onto the canonical slot");
|
|
||||||
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test("integration: a config change invalidates the STRUCTURED cache too (closes the #177 gap)", async () => {
|
|
||||||
if (!LT_POSIX) return;
|
|
||||||
const dir = ltMkdir(); const fake = ltFakeJson(dir); const counter = join(dir, "spawns.txt");
|
|
||||||
const rf = { type: "json_schema", json_schema: { name: "probe", schema: LT_SCHEMA } };
|
|
||||||
const req = { model: "sonnet", messages: [{ role: "user", content: "structured-epoch-probe" }], response_format: rf };
|
|
||||||
const bootOnce = async (env, port) => {
|
|
||||||
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter, ...env }, dir);
|
|
||||||
try {
|
|
||||||
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
|
|
||||||
_ltWrite(counter, "0");
|
|
||||||
await ltPost(port, req);
|
|
||||||
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 4000);
|
|
||||||
return Number(_ltRead(counter, "utf8")) || 0;
|
|
||||||
} finally { child.kill("SIGKILL"); }
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const off = await bootOnce({}, 39362); // caches under epoch(negative wrapper)
|
|
||||||
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, 39363); // same DB, epoch differs → must re-spawn
|
|
||||||
assert.equal(off, 1, "first structured request (cache empty) must spawn claude");
|
|
||||||
assert.equal(on, 1, "structured cache must honor CONFIG_EPOCH — before #194 it omitted the epoch entirely and served the stale answer");
|
|
||||||
} finally { _ltRm(dir, { recursive: true, force: true }); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Upgrade Tests ──
|
// ── Upgrade Tests ──
|
||||||
import { runUpgrade, postFlightOk } from "./scripts/upgrade.mjs";
|
import { runUpgrade, postFlightOk } from "./scripts/upgrade.mjs";
|
||||||
|
|
||||||
@@ -4257,10 +4067,6 @@ test("models.json aliases.sonnet === 'claude-sonnet-5' (default-request-model SP
|
|||||||
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-5");
|
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-5");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("models.json aliases.opus === 'claude-opus-5' (opus-alias SPOT)", () => {
|
|
||||||
assert.equal(_spotModels.aliases.opus, "claude-opus-5");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Referential integrity (PR #152 review) ──────────────────────────────────
|
// ── Referential integrity (PR #152 review) ──────────────────────────────────
|
||||||
// The value-mirror assertions above only prove the alias equals a string literal —
|
// The value-mirror assertions above only prove the alias equals a string literal —
|
||||||
// they pass even if that literal points at a model that does not exist in
|
// they pass even if that literal points at a model that does not exist in
|
||||||
@@ -4274,25 +4080,6 @@ test("models.json: claude-sonnet-5 is present in models[] (the entry this PR add
|
|||||||
assert.ok(_spotModelIds.has("claude-sonnet-5"), "claude-sonnet-5 must exist as a models[].id");
|
assert.ok(_spotModelIds.has("claude-sonnet-5"), "claude-sonnet-5 must exist as a models[].id");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("models.json: claude-opus-5 is present in models[] (the entry this PR adds)", () => {
|
|
||||||
assert.ok(_spotModelIds.has("claude-opus-5"), "claude-opus-5 must exist as a models[].id");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The prompt-char budget is GLOBAL (max across every entry × 3 chars/token), not
|
|
||||||
// per-model — see lib/prompt.mjs derivePromptCharBudget. An entry declaring a native 1M
|
|
||||||
// window would therefore raise the truncation ceiling for claude-haiku-4-5 too (genuinely
|
|
||||||
// 200k), turning OCP-side truncation into an upstream API rejection.
|
|
||||||
//
|
|
||||||
// Asserts the MAX, deliberately, not every entry: ADR 0009 states the budget "scales
|
|
||||||
// automatically — no code change", so a future entry with a SMALLER window (say a 128k
|
|
||||||
// model) must stay legal and must not fail this suite. Only raising the ceiling is the
|
|
||||||
// hazard, and that is an ADR-level decision requiring per-model budgets first.
|
|
||||||
test("models.json: max contextWindow is 200000 (global prompt-budget ceiling)", () => {
|
|
||||||
const windows = _spotModels.models.map(m => m.contextWindow);
|
|
||||||
assert.equal(Math.max(...windows), 200000,
|
|
||||||
`max contextWindow re-scales MAX_PROMPT_CHARS for ALL models incl. the 200k-native haiku (see lib/prompt.mjs + ADR 0009)`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("models.json: every aliases value resolves to a real models[].id (referential integrity)", () => {
|
test("models.json: every aliases value resolves to a real models[].id (referential integrity)", () => {
|
||||||
for (const [name, target] of Object.entries(_spotModels.aliases)) {
|
for (const [name, target] of Object.entries(_spotModels.aliases)) {
|
||||||
assert.ok(_spotModelIds.has(target), `aliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
|
assert.ok(_spotModelIds.has(target), `aliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
|
||||||
|
|||||||
Reference in New Issue
Block a user