Commit Graph
3 Commits
Author SHA1 Message Date
taodengandClaude Opus 4.7 cb86807009 refactor(routing): D17 — alias-aware getProviderForModel as routing SPOT (Findings 12 + 13)
cold-audit catch from 2026-05-23

Cold-audit Findings 12 + 13 (both P3, both routing layer). F12: mistral
plugin's models[] included canonical IDs + aliases while anthropic/codex
were canonical-only — inconsistent routing surface (request "sonnet"
returned 503 from anthropic but request "devstral" worked for mistral).
F13: getProviderForModel imported but never called; buildDefaultChain
duplicated the lookup loop — two SPOT-candidate code paths.

Per cold-audit reviewer's option (a): standardize models[] on canonical-
only across all 3 plugins; getProviderForModel becomes alias-aware via
models-registry.json; buildDefaultChain uses getProviderForModel as SPOT.

Changes (4 files, +242 / -50):

1. lib/providers/index.mjs:
   - At module load, builds `_aliasMap: Map<aliasString, {providerName,
     canonicalModel}>` by walking models-registry.json's providers[*].aliases
   - getProviderForModel signature extended: returns `{provider, name,
     canonicalModel}` (was `{provider, name}`). The canonicalModel field is
     the resolved canonical ID for callers that need to use it downstream
     (cache key, observability headers, log events)
   - 3-step resolution order: (1) alias map lookup → if hit AND provider
     loaded → return with canonicalModel; (2) direct canonical scan → return
     with canonicalModel = modelString; (3) null
   - "Alias known but provider not loaded" case correctly falls through
     to step 2 (which will also miss for an alias string) → returns null

2. lib/providers/mistral.mjs:
   - _registryModels stripped to canonical-only: removed the
     `...Object.keys(_registryEntry.aliases ?? {})` spread
   - mistral.models[] now matches anthropic/codex shape (canonical IDs only)
   - Comment block updated to point future readers at getProviderForModel
     as the SPOT

3. lib/fallback/engine.mjs:
   - buildDefaultChain's inline `for ([name, provider] of loadedProviders)`
     scan loop replaced with a single getProviderForModel() call
   - Chain hop's `model` field is now `match.canonicalModel` (was `modelString`)
     — so downstream consumers receive canonical
   - Explicit-chain config path unchanged (kept its pre-existing behavior;
     alias resolution in routing.chains config is a known limitation, tracked
     as a future improvement)

4. test-features.mjs:
   - 17 new tests in `D17 — alias-aware getProviderForModel` describe block:
     anthropic alias coverage (sonnet/opus/haiku/claude), openai aliases
     (codex/codex-spark/gpt5/gpt5-mini), mistral aliases (devstral/
     devstral-2/devstral-small/devstral-small-2), canonical pass-through,
     unknown model → null, alias-to-disabled-provider → null,
     buildDefaultChain integration with alias resolution
   - 3 existing mistral tests rewritten — they were asserting the pre-D17
     inconsistent shape (aliases in mistral.models[]). Now assert the
     post-D17 invariant (aliases NOT in models[]; routing via
     getProviderForModel instead)

Tests: 300 → 317 (+17 new). All pass on Node 20.

Pre-commit fold-in (per evidence-first checkpoint #4 — fold-ins themselves
need second-pass review):

- **D17 reviewer flagged C10**: original engine.mjs comment said
  "downstream (cache key, X-OLP-Model-Used, provider.spawn) receives the
  canonical ID rather than the alias string". The provider.spawn portion
  is incorrect — spawn reads `irRequest.model` (the user's original input),
  which is NEVER rewritten to canonical. Each provider CLI accepts its own
  aliases natively (claude accepts sonnet/opus/haiku; vibe accepts
  devstral/devstral-2; codex accepts its model IDs), so runtime behavior
  is fine, but the comment overstated what D17 actually changes.
  Folded in: corrected comment to honestly describe the canonical flow
  (cache key + X-OLP-Model-Used + logs receive canonical; spawn continues
  to receive irRequest.model). Same class of doc-code drift fix as D11's
  B1 (false maxConcurrent enforcement claim) and D16's "bypasses
  getOrCompute" drift — the discipline is maturing across D-days.

Authority:
- ADR 0002 § Provider contract (`models: string[]` is "models this provider
  serves")
  https://github.com/dtzp555-max/olp/blob/main/docs/adr/0002-plugin-architecture.md
- models-registry.json — single source of truth for (provider, model)
  metadata + alias→canonical mappings per AGENTS.md SPOT policy
- AGENTS.md § Project-specific constraints — "models-registry.json is the
  only place to add/edit (provider, model) metadata"
- CC 开发铁律 v1.6 § 10.x — Cold Audit Findings 12 + 13

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE_WITH_MINOR. Highest-value verification: walked
chain[0].model from buildDefaultChain through all downstream consumers
(computeCacheKey, X-OLP-Model-Used, log events) to confirm canonical
flow; then verified provider.spawn paths in anthropic.mjs:231 and
codex.mjs:248 read irRequest.model (user input), confirming the comment
overstatement (now fixed). Verified no alias-canonical collision exists
in current registry (12 aliases vs 10 canonical IDs, zero intersection).
Verified empty-registry edge case + provider-with-model-not-in-registry
edge case.

Follow-up items (reviewer's non-blocking observations, NOT in this PR):
- server.mjs:32 dead import of getProviderForModel — defer to D19 cleanup
- explicit-chain config path doesn't run alias resolution (routing.chains
  in ~/.olp/config.json) — pre-existing limitation, file as future issue

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:55:43 +10:00
taodengandClaude Opus 4.7 2cfd0b194e feat(phase-1): D10 P1 hardening — providers.enabled wiring + real streaming + spawn timeout
Folds in three production-blocking defects from external Codex review
round-3 of Phase 1 (D5+D6+D8+D9 already merged). Without these, OLP
returns 503 on every request even when `npm start` succeeds, streams
arrive as a single buffered burst after the spawn completes, and hung
CLIs block the engine forever despite ADR 0004 declaring spawn-timeout
a hard trigger.

P1.1 providers.enabled config wiring (ADR 0002 § Disable model):
- loadFallbackConfigSync() returns tri-field {chains, soft_triggers,
  providersEnabled}; server.mjs reads _startupConfig.providersEnabled
  and passes to loadProviders() at startup
- Empty / missing config → 0 enabled providers → 503 no_enabled_provider
  (matches v0.1 0-Enabled posture per ALIGNMENT.md § Provider Inventory)
- __setProvidersEnabled / __resetProvidersEnabled test seams; in-place
  Map mutation preserves existing direct-mutation patterns in Suite 13

P1.2 Real SSE streaming on single-hop cache-miss (ADR 0003 entry adapter
pattern, OpenAI /v1/chat/completions stream=true contract):
- New handleChatCompletions branch when ir.stream === true && chain.length
  === 1 && !bypassCache && !preCheckHit
- for await (const irChunk of provider.spawn(...)) writes SSE per chunk
  via res.write(irChunkToOpenAISSE(...)); accumulates streamedChunks for
  cacheStore.set on stop
- First-chunk rule preserved: error-before-first-chunk → sendError(502);
  error-after-first-chunk → truncated res.end(), no fallback
- Multi-hop chains (chain.length > 1) continue to use buffered
  executeWithFallback to keep fallback safety semantics

P1.3 Spawn timeout hard trigger (ADR 0004 § Trigger taxonomy bullet 4):
- SPAWN_TIMEOUT added to PROVIDER_ERROR_CODES (lib/providers/base.mjs)
  and HARD_TRIGGER_CODES (lib/fallback/engine.mjs)
- All three plugins (anthropic.mjs / codex.mjs / mistral.mjs) wrap drain
  loop with setTimeout (default 600_000ms, configurable via
  hints.maxSpawnTimeMs); on fire: proc.kill('SIGTERM') + reject pending
  drain promise with ProviderError(..., 'SPAWN_TIMEOUT')
- Timer cleared in finally; resolveNext/rejectNext atomically nulled in
  push() + timer-fire path to prevent late-fire double-settle

Tests 277 → 288 (+11). Suite 14 (4 providers.enabled), Suite 15
(3 streaming cache-miss real-time, including arrival-count >= 2
assertion that architecturally proves real streaming), Suite 16
(4 spawn timeout, including 2-hop chain advancement from timed-out
primary). 288/288 pass on Node 20.20.2 + Node 25.8.0.

Authorities:
- ADR 0002 § Disable model — config toggle, not plugin-removal
  https://github.com/dtzp555-max/olp/blob/main/docs/adr/0002-plugin-architecture.md
- ADR 0003 § Translation direction model — entry adapter for await pattern
  https://github.com/dtzp555-max/olp/blob/main/docs/adr/0003-intermediate-representation.md
- ADR 0004 § Trigger taxonomy + § Fallback safety (first-chunk rule)
  https://github.com/dtzp555-max/olp/blob/main/docs/adr/0004-fallback-engine.md
- OpenAI /v1/chat/completions stream=true (server-sent events,
  data: {chunk} per delta, terminator data: [DONE])
  https://platform.openai.com/docs/api-reference/chat/streaming

Reviewer (Iron Rule 10): fresh-context opus, independent of drafter.
Verdict: APPROVE_WITH_MINOR. Folded the one cheap minor before commit
(Suite 15a arrival-count assertion strengthened from chunks.length > 0
to arrivalTimestamps.length >= 2 — the prior assertion would have
admitted a buffered impl). Two remaining non-blocking notes deferred:
optional writeHead deferral (low value; single-hop guard makes pre-
content 200 + empty body safe), and version bump (Phase 1 ships as
v0.1.0 aggregate when D11–D16 land).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 06:57:11 +10:00
taodengandClaude Opus 4.7 (noreply@anthropic.com) 5960486542 feat(phase-1): land fallback engine (D9)
Phase 1 Day 6 — the architectural milestone of Phase 1. OLP is no longer
a single-provider proxy with cache; it is now a multi-provider router
with idempotent-failure-safe fallback. ADR 0004 ratified in v0.1 governance
is fully implemented for D9 scope. Configuration-driven chains activate
when a user populates ~/.olp/config.json routing.chains; at v0.1 default
(empty chains), behaviour is single-hop pass-through identical to D5.

Files:
  NEW:  lib/fallback/engine.mjs (~470 lines) — Trigger taxonomy, chain
        advancement, first-chunk safety, observability annotation.
  MOD:  server.mjs (+80 lines net) — wires executeWithFallback between IR
        construction and provider.spawn. Per-hop cache key isolation
        (ADR 0005 § Cross-provider fallback). Test seams added:
        __setFallbackConfig, __resetFallbackConfig, __clearCache.
  MOD:  test-features.mjs (+845 lines, 6 new suites = +55 tests).

Authority citations:
  ADR 0004 § Decision § Trigger taxonomy — Hard / Soft / Deterministic
    (deferred) / Cost-aware (deferred) implemented exactly per spec.
  ADR 0004 § Decision § Fallback safety — first-chunk rule satisfied at
    D9 by buffering composition (executeHopFn = collectAllChunks; server
    writes to res only after engine returns).
  ADR 0004 § Decision § Chain advancement — one-at-a-time iteration;
    originalError preserved from FIRST hop (not last) on exhaustion.
  ADR 0004 § Decision § No fallback for client-side errors — HTTP 400/
    401/403/404/422 stop the chain immediately; AUTH_MISSING also stops
    immediately (user-config failure, not provider-quota).
  ADR 0004 § Decision § Observability headers — X-OLP-Fallback-Hops set
    from engine return value; X-OLP-Fallback-Exhausted lists tried
    providers on exhaustion.
  ADR 0005 § Cross-provider fallback cache behavior — each fallback hop
    computes a fresh cache key with the hop's (provider, model) tuple;
    primary's cache entries cannot leak to secondary's hop.

Reviewer chain (Iron Rule 10):
  Implementer: sonnet (general-purpose).
  Fresh-context reviewer: opus. Verdict APPROVE_WITH_MINOR.
  Reviewer ran npm test (277/277 pass), read ADR 0004 + ADR 0005 end-
    to-end, verified first-chunk-safety composition, checked all
    trigger taxonomy mappings.

Reviewer non-blocking findings folded in this commit:
  1. Test label at test-features.mjs:3756 clarified. Original title
     "client error (400) → does NOT fall back" was misleading because
     the test actually exercises both-fail SPAWN_FAILED → exhausted
     path (400-no-fallback semantic is covered at unit level instead).
     Renamed + commented to match actual behaviour.
  2. triedProviders semantic (includes soft-skipped) documented in
     engine.mjs comment near the soft-trigger branch.
  3. SOFT_TRIGGER synthetic code documented in engine.mjs as engine-
     internal, NOT a member of base.mjs PROVIDER_ERROR_CODES.

Reviewer non-blocking suggestions deferred (open D-later questions):
  - Q1 cacheStatus on fallback hops: conservative 'miss', acceptable
    per ADR 0005 § Per-model isolation.
  - Q2 X-OLP-Fallback-Exhausted only when triedProviders > 1:
    acceptable per ADR 0004 spec.
  - Q3 soft-trigger quotaSnapshot always null at D9: acceptable per
    ADR 0004 § Consequences/Negative graceful-degrade clause. Phase 2
    will add quota poll-worker.
  - Q4 loadFallbackConfigSync sync-only: acceptable for bounded
    startup I/O.

Test count: 222 (D8) → 277 (D9). 6 new test suites cover trigger
taxonomy, engine chain advancement (including the load-bearing
originalError-from-FIRST-hop property), HTTP integration through
real server.mjs, soft trigger pre-fetch path, exhausted-chain
header emission.

Verification:
  node --check on all touched files: clean.
  npm test on Node 25.8.0: 277/277 pass in 266ms.
  Hygiene grep: zero personal-name/path/token hits.
  No new external npm deps.

Co-Authored-By: Claude Opus 4.7 (noreply@anthropic.com)
2026-05-23 22:09:39 +10:00