mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
feat+test+docs: D45 — server.mjs auth integration + lib/audit.mjs (#21)
* feat+test+docs: D45 — server.mjs auth integration + lib/audit.mjs (Phase 2 wire-up) Second Phase 2 implementation D-day. Wires the D44 lib/keys.mjs identity layer into the request flow + lands lib/audit.mjs per ADR 0007 § 6.2 + § 8. Closes ADR § 10 acceptance criteria #1 (per-key cache isolation), #2 (anonymous prod-default off), #3 (anonymous dev-mode on), #6 (post-revoke 401 within next request — full coverage with D45), #8 (audit ndjson round-trip), #10 (OLP_OWNER_TOKEN env override — full server-side coverage), #11 (providers_enabled 403 scope). Owner-vs-guest gating for /health + X-OLP-Fallback-Detail (criteria #4, #5) remains in D46 scope. NEW lib/audit.mjs (~110 lines): - appendAuditEvent(event, opts): one JSON event per line to ~/.olp/logs/audit.ndjson (file 0600, dir 0700). § 6.2 retry: warn + 1 retry; per-process drop counter + warn on second failure; NEVER throws. Per-call OLP_HOME env resolution (matches lib/keys.mjs). - getAuditDropCount(): for future /health surface. lib/keys.mjs extended: - loadAuthConfigSync({ olpHome }): reads auth block from ~/.olp/config.json with ADR § 7.2 defaults (allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only'). Never throws; missing file / malformed JSON falls back to defaults. - _resolveOlpHome(opts): precedence opts.olpHome → process.env.OLP_HOME → ~/.olp. Per-call resolution so tests + operator deployments can redirect without code edits. server.mjs auth middleware integration: - extractToken(req): parses Authorization Bearer / x-api-key. - authenticate(req): validateKey + 401 paths (auth_required vs invalid_or_revoked_key). - isProviderEnabled(olpIdentity, providerKey): '*' = all; else array allowlist. - _authConfig loaded at startup; warn auth_allow_anonymous_enabled when true. Test seams __setAuthConfig / __resetAuthConfig. - handleChatCompletions + handleModels both gated by authenticate at top. Audit ctx built throughout; res.on('finish') appends row + fires touchLastUsed async. - IDENTITY-VS-CREDENTIALS SEPARATION: olpIdentity (new validated identity) consumed for cache namespacing + providers_enabled + audit; authContext passed to provider.spawn() REMAINS null so providers continue their own credential discovery (env / keychain / file). Per-provider per-key credential mapping is Phase 3+ per ADR § 12. - handleChatCompletions chain filtered by isProviderEnabled; empty result returns 403 key_no_provider_access. - keyId = olpIdentity.keyId (replacing hardcoded '__anonymous__'). - Audit captures fields throughout: post-auth, post-IR, post-chain (success or exhausted). Status + latency populated on res.on('finish'). TESTS — Suite 20, +15 (499 → 514): 20a-d: header parsing + valid key happy paths (Bearer / x-api-key / invalid → 401) 20e: revoked key 401 (criterion #6 end-to-end) 20f: OLP_OWNER_TOKEN env override returns 200 (criterion #10 full) 20g: allow_anonymous=true + no header returns 200 (criterion #3) 20h + 20h-extra: providers_enabled=['mistral'] for anthropic model → 403; '*' baseline returns 200 (criterion #11) 20i: per-key cache namespace isolation (criterion #1 end-to-end) 20j + 20j-401: audit.ndjson written with § 8 schema fields + PII guard; 401 path also appends (criterion #8) 20k: filesystem key last_used_at populated post-request (D45 touch wire) 20l + 20l-200: /v1/models also enforces auth TEST-MODE SETUP (test-features.mjs): - process.env.OLP_HOME = mkdtempSync(...) at module load so audit + key writes don't pollute ~/.olp/. - __setAuthConfig({ allow_anonymous: true }) after server.mjs imports so pre-D45 HTTP integration tests (Suite 18 etc.) continue to pass. - Suite 20 explicitly overrides __setAuthConfig per-case to exercise production-default-off coverage. DOCUMENTATION: - AGENTS.md: lib/keys.mjs 🟡 marker updated + NEW lib/audit.mjs entry; Implementation-status-note + shipped-set updated. - README.md: Implementation Status table gains lib/audit.mjs row + lib/keys.mjs row updated; Known limitations Multi-key auth note rewritten to reflect D45 ship + D46 follow-up; new env-vars (OLP_HOME, OLP_OWNER_TOKEN) and auth config block surfaced. - CHANGELOG.md: D45 entry under Unreleased per release_kit overlay phase_rolling_mode discipline. AUTHORITY: - ADR 0007 (multi-key auth — §§ 5/6.2/7/9.4 implementation contracts + § 10 criteria #1/#2/#3/#6/#8/#10/#11 covered). - CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased. - Phase 2 kickoff handoff (~/.cc-rules/memory/handoffs/ 2026-05-25-phase-2-kickoff.md in cc-rules d9da966). - Standing autopilot grant (~/.cc-rules/memory/auto/ standing_autopilot_phase_2.md in cc-rules bf0ed9a). Verified: 514/514 pass via npm test (no regression in 499 existing tests; 15 new Suite 20 tests all green). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix+test+docs: D45 fold-in — CI fail recovery + opus reviewer P1/P2/P3 Fresh-context opus reviewer (PR #21) returned APPROVE_WITH_MINOR with 4 findings; CI Node 24 separately reported 9 Suite 20 failures (all 200-expecting tests). Root cause of CI: Suite 20 setup did not stub CLAUDE_CODE_OAUTH_TOKEN before mock spawn, so anthropic.mjs AUTH_MISSING pre-check fired and tests 502'd. (Local Node 22 had the env from the maintainer's claude install — masked the gap.) CI FIX — Suite 20 OAuth env stub Added ensureSuite20FakeOAuth / restoreSuite20OAuth helpers in makeSuite20Server / teardownSuite20. Matches the existing pattern in Suite 9 line ~2154 (test-fake-oauth-token-for-cache-tests). P1 — Real-streaming path audit fidelity Single-hop streaming success (server.mjs ~L1050, the most common deployed shape) did not populate auditCtx.provider / tried_providers / cache_status. Audit rows for streaming requests carried provider: null. Fixed by stamping these at the top of the streaming branch and amending error_code on the two streaming failure exit paths (streaming_error_after_first_chunk + streaming_error_before_first_chunk). New regression test 20j-stream: streaming request asserts the audit row's provider, cache_status, and tried_providers fields are populated. P2 — Global test tmpdir cleanup process.env.OLP_HOME = mkdtempSync(...) at test-features.mjs module load left /var/folders/.../olp-test-home-* leak per npm test run. Fixed by process.on('exit', () => rmSync(_GLOBAL_TEST_OLP_HOME)). Best-effort; swallows errors so exit handler never throws. P3 — handleModels 401 lacks OLP diagnostic headers handleChatCompletions 401 passes olpErrorHeaders({ startMs }); handleModels 401 did not. Aligned. DEFERRED — P2 tried_providers semantics on 403 Reviewer noted that key_no_provider_access 403 stamps original chain in tried_providers, but the field name implies hops actually dispatched. Either ADR § 8 amendment or D46+ semantic fix. Marked in CHANGELOG; not in this fold-in scope. Test count: 514 → 515 (+1 streaming-audit regression test 20j-stream; 14 existing Suite 20 tests still pass). Verified locally via npm test. CI Node 24 recovery via the OAuth env stub. Authority: PR #21 fresh-context opus reviewer findings; CI Node 24 run 26382758946 failure logs; CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+271
-10
@@ -48,6 +48,14 @@ import {
|
||||
buildDefaultChain,
|
||||
loadFallbackConfigSync,
|
||||
} from './lib/fallback/engine.mjs';
|
||||
// Phase 2 / D45 — multi-key auth integration per ADR 0007.
|
||||
import {
|
||||
validateKey,
|
||||
touchLastUsed,
|
||||
loadAuthConfigSync,
|
||||
ANONYMOUS_KEY_ID,
|
||||
} from './lib/keys.mjs';
|
||||
import { appendAuditEvent } from './lib/audit.mjs';
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -93,6 +101,25 @@ if (_softTriggersConfigured) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Auth config (Phase 2 / D45, ADR 0007 § 7.2) ───────────────────────────
|
||||
// auth.allow_anonymous default false (production-off). Test seam below.
|
||||
let _authConfig = loadAuthConfigSync();
|
||||
if (_authConfig.allow_anonymous === true) {
|
||||
logEvent('warn', 'auth_allow_anonymous_enabled', {
|
||||
message: 'config.json auth.allow_anonymous is true — all routes accept requests without an OLP API key; production deployments should set this to false (ADR 0007 § 7).',
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal — test seam: inject a synthetic auth config (no file I/O). */
|
||||
export function __setAuthConfig(config) {
|
||||
_authConfig = config ?? { allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only' };
|
||||
}
|
||||
|
||||
/** @internal — reset auth config to file-loaded state. */
|
||||
export function __resetAuthConfig() {
|
||||
_authConfig = loadAuthConfigSync();
|
||||
}
|
||||
|
||||
// ── Provider registry ─────────────────────────────────────────────────────
|
||||
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless
|
||||
// ~/.olp/config.json has providers.enabled.X = true.
|
||||
@@ -383,11 +410,88 @@ function withFallbackDetailHeader(baseHeaders, fallbackDetail) {
|
||||
|
||||
// ── Route handlers ────────────────────────────────────────────────────────
|
||||
|
||||
// ── Auth middleware (Phase 2 / D45, ADR 0007 § 5 + § 7) ───────────────────
|
||||
|
||||
/**
|
||||
* Extract the plaintext OLP token from request headers.
|
||||
* Tries `Authorization: Bearer <token>` first, then `x-api-key: <token>`.
|
||||
* Returns the token string or null.
|
||||
*
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function extractToken(req) {
|
||||
const auth = req.headers['authorization'];
|
||||
if (typeof auth === 'string') {
|
||||
const match = /^Bearer\s+(\S+)$/i.exec(auth);
|
||||
if (match) return match[1];
|
||||
}
|
||||
const xKey = req.headers['x-api-key'];
|
||||
if (typeof xKey === 'string' && xKey.length > 0) return xKey;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate the request per ADR 0007 §§ 5 / 7 / 9.4.
|
||||
* - Try env-owner override first (OLP_OWNER_TOKEN).
|
||||
* - Then filesystem manifest lookup by SHA-256 hash.
|
||||
* - Else, if auth.allow_anonymous is true → anonymous identity.
|
||||
* - Else → 401 auth_required.
|
||||
*
|
||||
* Returns { ok: true, authContext } on success or
|
||||
* { ok: false, status, code, message } on failure (401).
|
||||
*
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @returns {{ ok: true, authContext: { keyId: string, owner_tier: 'owner'|'guest'|'anonymous', providers_enabled: string[]|'*', source: 'env'|'filesystem'|'anonymous' } } | { ok: false, status: number, code: string, message: string }}
|
||||
*/
|
||||
function authenticate(req) {
|
||||
const token = extractToken(req);
|
||||
const identity = validateKey(token, { allowAnonymous: _authConfig.allow_anonymous });
|
||||
if (identity === null) {
|
||||
// Distinguish "no token presented" from "token presented but invalid/revoked".
|
||||
// Both surface as 401 to the client (don't leak which case it was), but the
|
||||
// server-side audit + log captures the source for operator diagnosis.
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
code: token ? 'invalid_or_revoked_key' : 'auth_required',
|
||||
message: token
|
||||
? 'OLP API key is invalid or has been revoked.'
|
||||
: 'OLP API key required. Pass via "Authorization: Bearer <token>" or "x-api-key: <token>".',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
authContext: {
|
||||
keyId: identity.id,
|
||||
owner_tier: identity.owner_tier,
|
||||
providers_enabled: identity.providers_enabled,
|
||||
source: identity.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given provider key is permitted for this identity.
|
||||
* `providers_enabled: '*'` grants all; an array is a whitelist.
|
||||
*
|
||||
* @param {{ providers_enabled: string[]|'*' }} authContext
|
||||
* @param {string} providerKey
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProviderEnabled(authContext, providerKey) {
|
||||
if (authContext.providers_enabled === '*') return true;
|
||||
return Array.isArray(authContext.providers_enabled) && authContext.providers_enabled.includes(providerKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
* Returns server health including count of loaded providers and per-provider
|
||||
* healthCheck() snapshots (ADR 0002 § Provider contract: healthCheck is used
|
||||
* by startup and /health endpoint per ADR 0002 § Provider contract description).
|
||||
*
|
||||
* Phase 2 / D45: no auth gate at this endpoint yet. Owner-vs-guest /health
|
||||
* payload trimming per ADR 0007 § 7.1 lands at D46.
|
||||
*/
|
||||
async function handleHealth(req, res) {
|
||||
const enabled = loadedProviders.size;
|
||||
@@ -423,6 +527,46 @@ async function handleHealth(req, res) {
|
||||
* Empty case: if no providers are enabled, data: [] is returned naturally.
|
||||
*/
|
||||
function handleModels(req, res) {
|
||||
const startMs = Date.now();
|
||||
|
||||
// Audit + auth for /v1/models per Phase 2 / D45 (ADR 0007 § 7).
|
||||
const auditCtx = {
|
||||
ts: new Date().toISOString(),
|
||||
key_id: ANONYMOUS_KEY_ID,
|
||||
owner_tier: 'anonymous',
|
||||
method: 'GET',
|
||||
path: '/v1/models',
|
||||
provider: null,
|
||||
model: null,
|
||||
status_code: 0,
|
||||
latency_ms: 0,
|
||||
cache_status: null,
|
||||
fallback_hops: 0,
|
||||
tried_providers: [],
|
||||
error_code: null,
|
||||
ir_request_hash: null,
|
||||
chain_id: null,
|
||||
};
|
||||
let _authedKeyId = null;
|
||||
res.on('finish', () => {
|
||||
auditCtx.status_code = res.statusCode;
|
||||
auditCtx.latency_ms = Date.now() - startMs;
|
||||
try { appendAuditEvent(auditCtx); } catch { /* best-effort */ }
|
||||
if (_authedKeyId && _authedKeyId !== ANONYMOUS_KEY_ID) {
|
||||
touchLastUsed(_authedKeyId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
const authResult = authenticate(req);
|
||||
if (!authResult.ok) {
|
||||
auditCtx.error_code = authResult.code;
|
||||
return sendError(res, authResult.status, authResult.message, authResult.code,
|
||||
olpErrorHeaders({ startMs }));
|
||||
}
|
||||
auditCtx.key_id = authResult.authContext.keyId;
|
||||
auditCtx.owner_tier = authResult.authContext.owner_tier;
|
||||
_authedKeyId = authResult.authContext.keyId;
|
||||
|
||||
const data = [];
|
||||
|
||||
// Canonical entries first
|
||||
@@ -472,9 +616,64 @@ function handleModels(req, res) {
|
||||
async function handleChatCompletions(req, res) {
|
||||
const startMs = Date.now();
|
||||
|
||||
// Audit context — fields populated as the request proceeds; § 8 schema.
|
||||
// Fired on res.on('finish') below regardless of success / error path.
|
||||
const auditCtx = {
|
||||
ts: new Date().toISOString(),
|
||||
key_id: ANONYMOUS_KEY_ID, // updated post-auth
|
||||
owner_tier: 'anonymous', // updated post-auth
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
provider: null,
|
||||
model: null,
|
||||
status_code: 0, // updated on finish
|
||||
latency_ms: 0, // updated on finish
|
||||
cache_status: null,
|
||||
fallback_hops: 0,
|
||||
tried_providers: [],
|
||||
error_code: null,
|
||||
ir_request_hash: null,
|
||||
chain_id: null,
|
||||
};
|
||||
// Wire post-response audit + lazy last_used_at update once, at the top, so
|
||||
// every code path below (401, 400, 500, 200, streaming) emits an audit row.
|
||||
// touchLastUsed is best-effort and never throws (§ 6.3).
|
||||
let _authedKeyId = null;
|
||||
res.on('finish', () => {
|
||||
auditCtx.status_code = res.statusCode;
|
||||
auditCtx.latency_ms = Date.now() - startMs;
|
||||
try { appendAuditEvent(auditCtx); } catch { /* audit is best-effort */ }
|
||||
if (_authedKeyId && _authedKeyId !== ANONYMOUS_KEY_ID) {
|
||||
// Anonymous + __env_owner__ have no on-disk manifest to touch; touchLastUsed
|
||||
// itself early-returns for those identities (§ 6.3 wrapper). For
|
||||
// filesystem-stored keys, fire-and-forget.
|
||||
touchLastUsed(_authedKeyId).catch(() => { /* warned internally */ });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Authentication (Phase 2 / D45, ADR 0007 § 5 + § 7) ───────────────────
|
||||
// olpIdentity carries the OLP-side identity (keyId / owner_tier /
|
||||
// providers_enabled). It is SEPARATE from `authContext` which is the
|
||||
// per-provider OAuth/credential artifact passed to provider.spawn().
|
||||
// Provider plugins treat `authContext === null` as "fall back to your
|
||||
// own credential discovery (env / keychain / file)" — that contract is
|
||||
// preserved at D45. Per-provider per-key credential mapping is a Phase
|
||||
// 3+ concern (ADR 0007 § 12 Out of scope; v0.1 spec § 4.5 anticipated).
|
||||
const authResult = authenticate(req);
|
||||
if (!authResult.ok) {
|
||||
auditCtx.error_code = authResult.code;
|
||||
return sendError(res, authResult.status, authResult.message, authResult.code,
|
||||
olpErrorHeaders({ startMs }));
|
||||
}
|
||||
const olpIdentity = authResult.authContext;
|
||||
auditCtx.key_id = olpIdentity.keyId;
|
||||
auditCtx.owner_tier = olpIdentity.owner_tier;
|
||||
_authedKeyId = olpIdentity.keyId;
|
||||
|
||||
// Require JSON content-type
|
||||
const ct = req.headers['content-type'] ?? '';
|
||||
if (!ct.includes('application/json')) {
|
||||
auditCtx.error_code = 'invalid_content_type';
|
||||
return sendError(res, 415, 'Content-Type must be application/json', 'invalid_request_error',
|
||||
olpErrorHeaders({ startMs }));
|
||||
}
|
||||
@@ -483,6 +682,7 @@ async function handleChatCompletions(req, res) {
|
||||
try {
|
||||
body = await readJSON(req);
|
||||
} catch (e) {
|
||||
auditCtx.error_code = 'invalid_request_body';
|
||||
return sendError(res, e.statusCode ?? 400, e.message, 'invalid_request_error',
|
||||
olpErrorHeaders({ startMs }));
|
||||
}
|
||||
@@ -493,22 +693,22 @@ async function handleChatCompletions(req, res) {
|
||||
ir = openAIToIR(body);
|
||||
} catch (e) {
|
||||
if (e instanceof BadRequestError) {
|
||||
auditCtx.error_code = 'invalid_ir';
|
||||
return sendError(res, 400, e.message, 'invalid_request_error',
|
||||
olpErrorHeaders({ startMs }));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Auth context is null at D5/D9 — providers fall back to their own credential
|
||||
// discovery (env var, keychain, credentials file). Phase 2 multi-key
|
||||
// infrastructure will pass a real authContext carrying the per-key OLP token.
|
||||
const authContext = null;
|
||||
auditCtx.model = ir.model;
|
||||
|
||||
// ── Fallback engine: build chain (ADR 0004) ─────────────────────────────
|
||||
// buildDefaultChain returns null if no enabled provider serves this model.
|
||||
// Per ADR 0004 § D9: at v0.1, chain is single-hop (no fallback) unless the
|
||||
// user has populated ~/.olp/config.json routing.chains.
|
||||
const chain = buildDefaultChain(
|
||||
//
|
||||
// `let` (not `const`) because the chain may be filtered below per the
|
||||
// authenticated key's providers_enabled allowlist (ADR 0007 § 10 #11).
|
||||
let chain = buildDefaultChain(
|
||||
ir.model,
|
||||
loadedProviders,
|
||||
_fallbackConfig.chains,
|
||||
@@ -517,6 +717,7 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
if (!chain) {
|
||||
// ALIGNMENT.md: 0 Enabled Providers at v0.1 → 503 per spec
|
||||
auditCtx.error_code = 'no_enabled_provider';
|
||||
return sendError(
|
||||
res, 503,
|
||||
`No enabled providers for model ${ir.model}. See README § Supported Providers.`,
|
||||
@@ -525,12 +726,39 @@ async function handleChatCompletions(req, res) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── providers_enabled scope enforcement (Phase 2 / D45, ADR 0007 § 10 #11) ─
|
||||
// Filter the chain to providers this key is authorized for. If the resulting
|
||||
// chain is empty, return 403 — the model exists in the registry but no hop
|
||||
// is reachable from this identity's allowlist.
|
||||
const _originalChainProviders = chain.map(hop => hop.provider);
|
||||
chain = chain.filter(hop => isProviderEnabled(olpIdentity, hop.provider));
|
||||
if (chain.length === 0) {
|
||||
auditCtx.error_code = 'key_no_provider_access';
|
||||
auditCtx.tried_providers = _originalChainProviders;
|
||||
const allowed = olpIdentity.providers_enabled === '*' ? '*' : (olpIdentity.providers_enabled ?? []).join(', ') || '(none)';
|
||||
return sendError(
|
||||
res, 403,
|
||||
`This OLP key does not have access to any provider serving model "${ir.model}". Key's providers_enabled: [${allowed}]. Chain providers: [${_originalChainProviders.join(', ')}].`,
|
||||
'key_no_provider_access',
|
||||
olpErrorHeaders({ startMs, model: ir.model }),
|
||||
);
|
||||
}
|
||||
|
||||
// Auth context is null at D45 — providers fall back to their own credential
|
||||
// discovery (env var, keychain, credentials file). The OLP-side identity
|
||||
// (olpIdentity above) is consumed for cache namespacing + providers_enabled
|
||||
// gating + audit attribution. Per-provider per-key credential mapping is a
|
||||
// Phase 3+ deferral (ADR 0007 § 12).
|
||||
const authContext = null;
|
||||
|
||||
const requestId = generateRequestId();
|
||||
|
||||
// ── Cache layer (ADR 0005) ──────────────────────────────────────────────
|
||||
// keyId: '__anonymous__' at D5/D9. Phase 2 multi-key infrastructure wires the
|
||||
// real OLP API key ID here for D1 per-key isolation.
|
||||
const keyId = '__anonymous__';
|
||||
// ── Cache layer (ADR 0005, namespaced per OLP key per ADR 0007 § 7) ─────
|
||||
// keyId is the authenticated identity's namespace token. For anonymous
|
||||
// (when auth.allow_anonymous=true) this is the legacy '__anonymous__'
|
||||
// shared namespace; for filesystem keys it is the per-key id; for the
|
||||
// OLP_OWNER_TOKEN env override it is the synthetic '__env_owner__'.
|
||||
const keyId = olpIdentity.keyId;
|
||||
|
||||
// D2 bypass (per-hop, per ADR 0005 § D2):
|
||||
// cache_control markers bypass OLP's response cache ONLY when the active hop
|
||||
@@ -829,11 +1057,21 @@ async function handleChatCompletions(req, res) {
|
||||
if (!streamPlugin) {
|
||||
// Provider disappeared between chain build and here (edge case).
|
||||
// Release the slot we acquired above so the counter stays balanced.
|
||||
auditCtx.provider = streamProvider;
|
||||
auditCtx.error_code = 'no_enabled_provider';
|
||||
releaseSpawn(streamProvider);
|
||||
return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider',
|
||||
olpErrorHeaders({ startMs, model: ir.model }));
|
||||
}
|
||||
|
||||
// D45 fold-in P1: populate audit ctx for the real-streaming path. Each
|
||||
// exit below (success, error-after-first-chunk, pre-first-chunk-error,
|
||||
// 503 above) leaves these fields representing the streaming attempt.
|
||||
// Error paths amend `error_code`; success leaves it null.
|
||||
auditCtx.provider = streamProvider;
|
||||
auditCtx.tried_providers = [streamProvider];
|
||||
auditCtx.cache_status = 'miss';
|
||||
|
||||
const streamHeaders = olpHeaders({
|
||||
providerUsed: streamProvider,
|
||||
modelUsed: streamModel,
|
||||
@@ -861,12 +1099,15 @@ async function handleChatCompletions(req, res) {
|
||||
model: streamModel,
|
||||
error: irChunk.error,
|
||||
});
|
||||
auditCtx.error_code = 'streaming_error_after_first_chunk';
|
||||
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
|
||||
res.write(SSE_DONE);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
// No bytes written yet — throw to surface a clean error.
|
||||
// auditCtx.error_code is set by the downstream catch handler (the
|
||||
// outer streaming-path catch block fills it from the thrown error).
|
||||
throw new ProviderError(irChunk.error ?? 'Provider emitted error chunk', 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
@@ -972,6 +1213,7 @@ async function handleChatCompletions(req, res) {
|
||||
model: streamModel,
|
||||
error: e.message,
|
||||
});
|
||||
auditCtx.error_code = e?.code ?? 'provider_error';
|
||||
if (!res.headersSent) {
|
||||
sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
|
||||
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
|
||||
@@ -1059,6 +1301,18 @@ async function handleChatCompletions(req, res) {
|
||||
fallbackHops: fallbackHops ?? 0,
|
||||
});
|
||||
|
||||
// Audit ctx capture for chain-exhausted / provider-error path (audit fires
|
||||
// on res.on('finish'); fields populated here so the row reflects what we
|
||||
// know at exhaustion time — provider is the chain[0] primary per ADR 0004
|
||||
// step 4, tried_providers is the failed-hop trail from fallbackDetail).
|
||||
auditCtx.provider = providerUsed ?? 'none';
|
||||
auditCtx.fallback_hops = fallbackHops ?? 0;
|
||||
auditCtx.tried_providers = Array.isArray(fallbackDetail)
|
||||
? fallbackDetail.map(t => t?.provider).filter(Boolean)
|
||||
: [];
|
||||
auditCtx.cache_status = 'miss';
|
||||
auditCtx.error_code = originalError?.code ?? 'provider_error';
|
||||
|
||||
// Send error with standard OLP headers + optional exhausted header +
|
||||
// D40 X-OLP-Fallback-Detail (when any hop attempted to spawn failed).
|
||||
// D40 (issue #7) — ungated v0.1 per maintainer decision; owner-vs-non-owner
|
||||
@@ -1103,6 +1357,13 @@ async function handleChatCompletions(req, res) {
|
||||
fallbackDetail,
|
||||
);
|
||||
|
||||
// Audit ctx capture for success path (audit fires on res.on('finish');
|
||||
// status_code + latency_ms populated then).
|
||||
auditCtx.provider = providerUsed;
|
||||
auditCtx.fallback_hops = fallbackHops;
|
||||
auditCtx.tried_providers = (Array.isArray(fallbackDetail) ? fallbackDetail.map(t => t?.provider).filter(Boolean) : []).concat([providerUsed]);
|
||||
auditCtx.cache_status = cacheStatus;
|
||||
|
||||
if (ir.stream) {
|
||||
// Streaming response path: burst replay from buffered chunks.
|
||||
// Reaches here only when: bypassCacheForFirstHop=true OR preCheckHit=true OR chain.length>1.
|
||||
|
||||
Reference in New Issue
Block a user