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:
@@ -28,6 +28,7 @@
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
import type { ChatSettings } from "./contracts/chat.ts";
|
||||
import type { SearchEngineId } from "./search.ts";
|
||||
@@ -133,6 +134,56 @@ export class ModelsNotConfiguredError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** The key-presence refusal (provider-key interlude, owner, 2026-09-25):
|
||||
* a PINNED model is not enough — its provider's key must be present, or
|
||||
* the action dies mid-stream masked to a generic error. Same envelope
|
||||
* family as modelsNotConfigured: HTTP callers get 503 + this JSON;
|
||||
* in-band consumers (the job record's error field) carry the human
|
||||
* `error` line. `provider` names whose key is gone; `slots` names the
|
||||
* slot tier(s) the refused action resolves to that provider. */
|
||||
export const PROVIDER_KEY_MISSING_CODE = "provider_key_missing" as const;
|
||||
export interface ProviderKeyMissing {
|
||||
/** Human-readable — safe to render as-is. */
|
||||
error: string;
|
||||
/** The machine discriminator the client switches on. */
|
||||
code: typeof PROVIDER_KEY_MISSING_CODE;
|
||||
/** The provider whose key is gone, e.g. "openrouter". */
|
||||
provider: string;
|
||||
/** Which slot tiers the refused action resolves to that provider. */
|
||||
slots: ModelSlotName[];
|
||||
}
|
||||
/** The refusal wording's invariant tail — the matcher below keys on it
|
||||
* (the label prefix varies by provider). */
|
||||
const PROVIDER_KEY_MISSING_TAIL = "key is set — add one in Settings → Keys";
|
||||
export function providerKeyMissing(provider: string, slots: ModelSlotName[]): ProviderKeyMissing {
|
||||
const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider;
|
||||
return {
|
||||
error: `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`,
|
||||
code: PROVIDER_KEY_MISSING_CODE,
|
||||
provider,
|
||||
slots,
|
||||
};
|
||||
}
|
||||
/** The refusal error text (same discipline as isModelsRefusal): UI
|
||||
* consumers detect key-refused work by the wording (info tone, not
|
||||
* scary-error) — the envelope's home module owns the string so the
|
||||
* writer and the matcher can never drift. */
|
||||
export function isProviderKeyRefusal(error: string | undefined): boolean {
|
||||
return !!error && error.endsWith(PROVIDER_KEY_MISSING_TAIL);
|
||||
}
|
||||
/** The typed throw for in-process seams (extraction pipeline) — the
|
||||
* ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch
|
||||
* it to skip honestly (trigger) or surface the 503 envelope (routes). */
|
||||
export class ProviderKeyMissingError extends Error {
|
||||
readonly envelope: ProviderKeyMissing;
|
||||
constructor(provider: string, slots: ModelSlotName[]) {
|
||||
const envelope = providerKeyMissing(provider, slots);
|
||||
super(envelope.error);
|
||||
this.name = "ProviderKeyMissingError";
|
||||
this.envelope = envelope;
|
||||
}
|
||||
}
|
||||
|
||||
/** One slot's stored state. BYOK (owner, 2026-09-22): the WRITE side
|
||||
* requires model (a level alone can never run — no factory floor); the
|
||||
* sanitizer keeps whatever is on disk, but RESOLUTION treats a slot
|
||||
|
||||
Reference in New Issue
Block a user