Key-presence refusal at every gate: a pinned model is not enough
Owner-diagnosed: the turn gate checked only model coordinates, so a pinned slot whose provider key was deleted passed the gate and died mid-stream masked to 'An error occurred.' The key is now gated where the model is — by what would RUN (env-first keyValue truth, so an env-shadowed key passes). - core/settings.ts: the provider_key_missing envelope family (code, provider, slots, human line naming the provider from AUTH_PROVIDER_KEYS labels) + ProviderKeyMissingError + isProviderKeyRefusal matcher. - chat-handler turn gate: refuses 503 before beginTurn when the resolved conversation's provider key is gone (slots: [strong]). - Job runner: same check via an injected dep (agent layer cannot read the key store) — a keyless engine refuses the spawn through the existing failure-shape convention, no phantom run. - Extraction pipeline: refuseIfStagesUnready refuses BEFORE any batch (first offender: extractor before auditor); the trigger skips info- level, /api/graph/import surfaces the 503 envelope per file. - Tests: 8 new pins (turn gate both ways, runner refusal, pipeline first-offender + pass-through, envelope factory, matcher); the three turn-driving suites seed OPENROUTER_API_KEY like any real setup. - Doc fix: stale EXTRACTION.chunkTokens comment in the manual-extract route (left over from eed4b4e). Known residual (pre-existing, flagged for master): the manual extract route logs BOTH config refusals as console.error (fire-and-forget path); the coverage ledger keeps everything uncovered either way.
This commit is contained in:
@@ -4,7 +4,14 @@ import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { z } from "zod";
|
||||
import { lhTool, toPiTools } from "../lh-tool.ts";
|
||||
import type { ModelRole } from "../../../config.ts";
|
||||
import { FACTORY_SETTINGS, ModelsNotConfiguredError, ROLE_SLOT, type ModelSlotName } from "../../core/settings.ts";
|
||||
import {
|
||||
FACTORY_SETTINGS,
|
||||
ModelsNotConfiguredError,
|
||||
ProviderKeyMissingError,
|
||||
ROLE_SLOT,
|
||||
type ModelSlotName,
|
||||
type ProviderKeyMissing,
|
||||
} from "../../core/settings.ts";
|
||||
import type { PiEngine } from "../models.ts";
|
||||
import { runSubagent } from "./pi-run.ts";
|
||||
import type {
|
||||
@@ -566,6 +573,14 @@ export interface ExtractionModels {
|
||||
* slot → NOTHING) may end EMPTY → undefined; the front doors refuse
|
||||
* with ModelsNotConfiguredError before any stage runs. */
|
||||
engineFor?: (role: ModelRole) => PiEngine | undefined;
|
||||
/** Key-presence gate (provider-key interlude, 2026-09-25): given a
|
||||
* stage engine's provider + the role's slot tier, the injected check
|
||||
* returns the refusal envelope when the provider's key is missing
|
||||
* (null = run). Same injection discipline as the job runner's seam
|
||||
* (the agent layer may not read the key store); absent in tests that
|
||||
* inject engines directly (test engines never owe a key). A keyless
|
||||
* stage refuses with ProviderKeyMissingError BEFORE any batch runs. */
|
||||
providerKeyMissing?: (provider: string, tier: ModelSlotName) => ProviderKeyMissing | null;
|
||||
}
|
||||
|
||||
export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {
|
||||
@@ -623,15 +638,30 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
const stageEngine = (role: ModelRole, override?: PiEngine): PiEngine | undefined =>
|
||||
override ?? deps.engineFor?.(role);
|
||||
/** The per-need refusal guard: which slot tiers the pipeline's stages
|
||||
* found unset (test values count as configured — they ARE engines). */
|
||||
function refuseIfStagesUnconfigured(): void {
|
||||
* found unset (test values count as configured — they ARE engines),
|
||||
* and whether a configured stage's provider key is gone (provider-key
|
||||
* interlude, 2026-09-25) — both refuse BEFORE any batch runs. */
|
||||
function refuseIfStagesUnready(): void {
|
||||
const stages: readonly [ModelRole, PiEngine | undefined][] = [
|
||||
["extractor", stageEngine("extractor", deps.extractor)],
|
||||
["auditor", stageEngine("auditor", deps.auditor)],
|
||||
];
|
||||
const missing = new Set<ModelSlotName>();
|
||||
if (!stageEngine("extractor", deps.extractor)) missing.add(ROLE_SLOT.extractor);
|
||||
if (!stageEngine("auditor", deps.auditor)) missing.add(ROLE_SLOT.auditor);
|
||||
for (const [role, engine] of stages) if (!engine) missing.add(ROLE_SLOT[role]);
|
||||
if (missing.size > 0) {
|
||||
console.info(`[extraction] refused — no model configured for: ${[...missing].join(", ")}`);
|
||||
throw new ModelsNotConfiguredError([...missing]);
|
||||
}
|
||||
// Key-presence parity: first offender surfaces (extractor before
|
||||
// auditor); fixing that key re-gates and the next run surfaces the
|
||||
// next offender, if any.
|
||||
for (const [role, engine] of stages) {
|
||||
const refusal = deps.providerKeyMissing?.(engine!.model.provider, ROLE_SLOT[role]);
|
||||
if (refusal) {
|
||||
console.info(`[extraction] refused — ${JSON.stringify(refusal)}`);
|
||||
throw new ProviderKeyMissingError(refusal.provider, refusal.slots);
|
||||
}
|
||||
}
|
||||
}
|
||||
// THE SHARED REGISTRIES (runs() above — globalThis): every pipeline
|
||||
// instance (route entries in dev, tests) sees the same runs, the same
|
||||
@@ -758,10 +788,11 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
try {
|
||||
const record = chatStore.getChat(chatId);
|
||||
if (!record) throw new Error(`Chat not found: ${chatId}`);
|
||||
// BYOK per-need gate (owner, 2026-09-22): the write channel needs
|
||||
// extractor + auditor engines; an empty chain refuses BEFORE any
|
||||
// BYOK per-need gate (owner, 2026-09-22) + key presence
|
||||
// (2026-09-25): the write channel needs extractor + auditor
|
||||
// engines AND their providers' keys; a refusal lands BEFORE any
|
||||
// stage runs (typed — the trigger skips, routes surface 503).
|
||||
refuseIfStagesUnconfigured();
|
||||
refuseIfStagesUnready();
|
||||
const covered = coverageOf(record.messages as UIMessage[], graphStore.readCoverage(), chatId);
|
||||
if (covered.uncovered.length === 0) {
|
||||
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
|
||||
@@ -921,9 +952,10 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
inFlight.add(id);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
// BYOK per-need gate (owner, 2026-09-22): same refusal as the chat
|
||||
// front door — no extractor/auditor engines, no run.
|
||||
refuseIfStagesUnconfigured();
|
||||
// BYOK per-need gate (owner, 2026-09-22) + key presence
|
||||
// (2026-09-25): same refusal as the chat front door — no
|
||||
// extractor/auditor engines, or a stage key gone, no run.
|
||||
refuseIfStagesUnready();
|
||||
console.info(`[extraction ${id}] notes pipeline start (${input.filePath})`);
|
||||
setPhase(id, "extracting", "notes", input.filePath);
|
||||
const findings = await notesExtract(input);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "../../core/contracts.ts";
|
||||
import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
|
||||
import type { ModelRole } from "../../../config.ts";
|
||||
import { ROLE_SLOT, modelsNotConfigured } from "../../core/settings.ts";
|
||||
import { ROLE_SLOT, modelsNotConfigured, type ModelSlotName, type ProviderKeyMissing } from "../../core/settings.ts";
|
||||
import type { ChatStore, JobStore } from "../../core/ports.ts";
|
||||
import { runDemoBuilder } from "./demo-builder.ts";
|
||||
import { runPdfReaderAgent } from "./pdf-reader.ts";
|
||||
@@ -84,6 +84,12 @@ export interface JobRunnerDeps {
|
||||
* drop (jobTools' searchAvailable) uses the same signal — jobs already
|
||||
* in flight when a key disappears still fail honestly, never crash. */
|
||||
searchProvider?: () => SearchProvider | null;
|
||||
/** Key-presence gate (provider-key interlude, 2026-09-25): given the
|
||||
* resolved engine's provider + the kind's slot tier, return the
|
||||
* refusal envelope when the provider's key is missing (null = run).
|
||||
* Injected because the agent layer may not read the key store; absent
|
||||
* in tests that inject engines directly (test engines never owe a key). */
|
||||
providerKeyMissing?: (provider: string, tier: ModelSlotName) => ProviderKeyMissing | null;
|
||||
}
|
||||
|
||||
export interface JobRunner {
|
||||
@@ -189,18 +195,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
return;
|
||||
}
|
||||
const engine = pickEngine(job.kind);
|
||||
if (!engine) {
|
||||
// BYOK per-need refusal (owner, 2026-09-22): this kind's tier has
|
||||
// no model. Refuse through the SAME convention as every other
|
||||
// failed job — record the honest error (no status flip:
|
||||
// finalizeJob owns the terminal flip after delivery), then steer
|
||||
// the kind's failure shape through finishJob so the wake
|
||||
// pipeline resumes the lesson and the teacher reads the refusal
|
||||
// instead of waiting forever on a job it already dispatched.
|
||||
const tier = ROLE_SLOT[KIND_ROLE[job.kind]];
|
||||
const envelope = modelsNotConfigured([tier]);
|
||||
const reason = `${envelope.error} (missing: ${tier})`;
|
||||
console.info(`[job ${jobId}] refused — ${JSON.stringify(envelope)}`);
|
||||
// The per-need refusal guards (BYOK 2026-09-22 / provider-key
|
||||
// interlude 2026-09-25): no engine for the kind's tier, or the
|
||||
// resolved engine's provider key gone — both refuse through the SAME
|
||||
// convention as every other failed job: record the honest error (no
|
||||
// status flip: finalizeJob owns the terminal flip after delivery),
|
||||
// then steer the kind's failure shape through finishJob so the wake
|
||||
// pipeline resumes the lesson and the teacher reads the refusal
|
||||
// instead of waiting forever on a job it already dispatched.
|
||||
const refuse = async (reason: string): Promise<void> => {
|
||||
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
||||
const resolved = resolveInput(job, input) as
|
||||
| ResearchToolInput
|
||||
@@ -229,6 +232,23 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
pagesConsulted: [],
|
||||
};
|
||||
await finishJob(jobId, failure);
|
||||
};
|
||||
if (!engine) {
|
||||
const tier = ROLE_SLOT[KIND_ROLE[job.kind]];
|
||||
const envelope = modelsNotConfigured([tier]);
|
||||
console.info(`[job ${jobId}] refused — ${JSON.stringify(envelope)}`);
|
||||
await refuse(`${envelope.error} (missing: ${tier})`);
|
||||
return;
|
||||
}
|
||||
// Key presence (2026-09-25): the engine exists, but its provider's
|
||||
// key may be gone — refuse the spawn BEFORE the loop, same
|
||||
// convention (the injected check reads the key store; absent in
|
||||
// engine-injecting tests).
|
||||
const keyRefusal =
|
||||
deps.providerKeyMissing?.(engine.model.provider, ROLE_SLOT[KIND_ROLE[job.kind]]) ?? null;
|
||||
if (keyRefusal) {
|
||||
console.info(`[job ${jobId}] refused — ${JSON.stringify(keyRefusal)}`);
|
||||
await refuse(keyRefusal.error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user