diff --git a/TODO.md b/TODO.md index 197ed27..4c102b5 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,12 @@ ## Active tickets +- [ ] [`ticket-034`](project/ticket-034/README.md) — scale each OpenRouter chat + deadline deterministically from input size, output budget and structural + complexity. Current state: `IN_PROGRESS / PUBLICATION`; governance, 349-test + verification, gold, SDK examples and Docker smoke pass on the validated + ticket-027 publication base. + - [ ] [`ticket-018`](project/ticket-018/README.md) — enforce the `wellmanifest/new-project` manifest as policy-as-code through a deterministic validator, trusted approval boundary, reusable governance CI, stack-specific diff --git a/project/TICKETS.md b/project/TICKETS.md index d3af4a6..cd6d91c 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -33,4 +33,5 @@ of `project/README.md`, which remains a generated technical-analysis artifact. | **ticket-026** | [`README.md`](./ticket-026/README.md) | [`preprompt.md`](./ticket-026/preprompt.md) | - | [`ai-codex.md`](./ticket-026/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-026/ai-codex-logs.txt) | [`changelog.md`](./ticket-026/changelog.md) | | **ticket-027** | [`README.md`](./ticket-027/README.md) | [`preprompt.md`](./ticket-027/preprompt.md) | - | [`ai-codex.md`](./ticket-027/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-027/ai-codex-logs.txt) | [`changelog.md`](./ticket-027/changelog.md) | | **ticket-031** | [`README.md`](./ticket-031/README.md) | [`preprompt.md`](./ticket-031/preprompt.md) | - | [`ai-codex.md`](./ticket-031/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-031/ai-codex-logs.txt) | [`changelog.md`](./ticket-031/changelog.md) | +| **ticket-034** | [`README.md`](./ticket-034/README.md) | [`preprompt.md`](./ticket-034/preprompt.md) | - | [`ai-codex.md`](./ticket-034/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-034/ai-codex-logs.txt) | [`changelog.md`](./ticket-034/changelog.md) | diff --git a/project/ticket-034/README.md b/project/ticket-034/README.md new file mode 100644 index 0000000..adf7738 --- /dev/null +++ b/project/ticket-034/README.md @@ -0,0 +1,99 @@ +# Ticket 034: Scale LLM timeout by input complexity + +- **ID**: ticket-034 +- **Owner**: unresolved:human +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-08-04 + +## Goal and scope + +Derive each OpenRouter request timeout from the configured base timeout, input +size, requested output size and structural complexity. Small requests retain the +current timeout. Crossing a baseline doubles it; each further doubling of load +doubles it again, up to a bounded maximum. + +This responds to a live Subactor audit where a short NL request completed, while +the bounded multi-document pipeline legitimately ran for several minutes. The +change must distinguish one-request timeout from total pipeline duration and +must not hide exhausted-credit, schema or external-cancellation failures. + +## Proposed deterministic policy + +For a chat-completion body calculate: + +- `inputRatio = serialized request characters / 8_000`; +- `outputRatio = max_tokens / 6_000`; +- `complexityRatio = complexity points / 4`, where message count contributes + one point, strict JSON Schema contributes two, and response healing contributes + one; +- `pressure = max(1, inputRatio, outputRatio, complexityRatio)`; +- `steps = ceil(log2(pressure))`; +- `multiplier = min(8, 2^steps)`; +- `effectiveTimeout = min(600_000 ms, baseTimeout * multiplier)`. + +Therefore an input just above the baseline gets `2×`, above twice the baseline +gets `4×`, and above four times gets `8×`. The existing +`OPENROUTER_TIMEOUT_MS` and documentation-specific base timeout remain minimums, +not replaced defaults. + +## Bounded implementation paths + +- `src/llm/openrouter-timeout.ts`: pure pressure/timeout calculation. +- `src/llm/openrouter.ts`: apply the effective timeout to chat completion + requests and report base/effective values on timeout. +- `src/llm/audit.ts`: persist the non-secret scaling policy with LLM audit + configuration. +- `test/openrouter-timeout.test.ts`: boundary, cap and cancellation regressions. +- Governance evidence under `project/ticket-034/**` and indexes. + +Model selection, token budgets, retry counts, chunking, concurrency, provider +fallback and the `/models` endpoint are out of scope. + +## Acceptance criteria + +- [x] AC-01: A human approves the formula and bounded paths after ticket-027 is + integrated or closed. +- [x] AC-02: Requests at or below all baselines retain the exact configured base + timeout. +- [x] AC-03: Crossing one, two and four baseline units produces `2×`, `4×` and + `8×` timeouts respectively. +- [x] AC-04: The result never exceeds 600 seconds and rejects non-finite or + malformed request values without silently granting an unbounded timeout. +- [x] AC-05: Structured schemas and response-healing complexity contribute to + scaling independently of raw character count. +- [x] AC-06: External `AbortSignal` cancellation remains immediate and is never + extended by adaptive timeout logic. +- [x] AC-07: Retry backoff remains inside one effective request deadline; the + change does not multiply each retry into a separate unbounded deadline. +- [x] AC-08: Timeout errors state both base and effective milliseconds; audit + configuration records the factor, baselines and cap without secrets. +- [x] AC-09: Focused tests, full `npm run verify`, Docker smoke and governance + pass on the integrated base. + +## Validation + +- `make governance`: PASS, 0 errors and 0 warnings. +- `npm run verify`: PASS, 349 tests, 348 passed, 1 optional JDK skip. +- `npm run evaluate:gold`: PASS, all measured precision and recall 100%. +- `npm run examples:check`: PASS, five SDK fingerprints agree. +- `make docker-smoke`: PASS. + +## Resolved blockers + +- Ticket-027 was closed on the validated repair line at `c51bf19`. The current + refactored base already contains its array narrowing and total edit-path + handling in the split helper modules, so importing its full historical stack + would only introduce unrelated conflicts. +- Implementation uses this clean ticket worktree. The unrelated edits in the + main development worktree remain untouched. + +## Approval boundary + +The user's `kontynuuj` on 2026-08-04 approves this formula and bounded scope. +The ticket may enter `EDIT`; protected review remains required for merge. + +## Participants + +- Human participant: unresolved; no `user-*` file was created. +- Agent participant: [ai-codex.md](ai-codex.md) diff --git a/project/ticket-034/ai-codex-logs.txt b/project/ticket-034/ai-codex-logs.txt new file mode 100644 index 0000000..c465edb --- /dev/null +++ b/project/ticket-034/ai-codex-logs.txt @@ -0,0 +1,9 @@ +2026-08-04 user approval: "kontynuuj"; state WAIT_FOR_APPROVAL -> EDIT +2026-08-04 local OpenRouter model: z-ai/glm-5.2; ignored .env only, no secret changed +2026-08-04 ticket-027 closure verified at c51bf19; equivalent split behavior present on current base +2026-08-04 focused OpenRouter suites PASS: 26/26 including 7 adaptive timeout tests +2026-08-04 make governance PASS: 0 errors, 0 warnings +2026-08-04 npm run verify PASS: 349 total, 348 pass, 0 fail, 1 optional JDK skip +2026-08-04 gold v2 PASS: all measured precision/recall 100%; stability PASS +2026-08-04 examples PASS: five SDK fingerprints agree +2026-08-04 Docker smoke PASS; state EDIT -> PUBLICATION diff --git a/project/ticket-034/ai-codex.md b/project/ticket-034/ai-codex.md new file mode 100644 index 0000000..2f9b966 --- /dev/null +++ b/project/ticket-034/ai-codex.md @@ -0,0 +1,43 @@ +--- +participant-id: agent:codex +participant: codex +role: agent +ticket: ticket-034 +--- +# Participant: codex (AI agent) + +## Understanding + +The configured timeout is currently a fixed deadline for the complete request, +including transport retries. It does not account for prompt/schema size or +requested output. Documentation has a separate 45-second base, but large strict +JSON requests can therefore receive less time than much smaller generic calls. + +## Execution plan + +1. Confirm the active LLM repair is closed and its behavior is present in the + current split implementation. +2. Add a pure bounded timeout calculator with explicit baselines and factor. +3. Apply it once per chat request before creating the abort timer. +4. Keep external cancellation and retry behavior unchanged. +5. Expose the policy in safe audit configuration and timeout errors. +6. Run boundary tests, full verification, Docker smoke and governance. + +## Actual changes + +- Created ticket-034 and recorded the proposed formula. +- Recorded the user's explicit continuation as approval and entered `EDIT`. +- Configured the ignored local OpenRouter environment to use `z-ai/glm-5.2`; + no API key or other secret was changed. +- Added a pure timeout policy and applied one effective deadline across HTTP + retries and their abortable backoff. +- Added timeout policy fields to secret-free audit configuration and base plus + effective durations to timeout errors. +- Added seven boundary, cap, malformed-input, audit and cancellation tests. +- Full verify, gold, SDK examples, governance and Docker smoke pass on the + validated ticket-027 publication base. + +## Blockers + +- Implementation and validation are complete. Protected review remains an + external publication requirement. diff --git a/project/ticket-034/changelog.md b/project/ticket-034/changelog.md new file mode 100644 index 0000000..2707b7b --- /dev/null +++ b/project/ticket-034/changelog.md @@ -0,0 +1,20 @@ +# Ticket Changelog (ticket-034) + +## [0.1.0] - 2026-08-04 + +- Created the adaptive LLM timeout governance plan. +- Defined deterministic `1×`/`2×`/`4×`/`8×` scaling and a 600-second cap. +- Recorded ticket-027 and the dirty OpenRouter refactor as blockers. +- Stopped at `BACKLOG / WAIT_FOR_APPROVAL`; no executable files changed. +- Recorded the user's approval and moved to `IN_PROGRESS / EDIT`. +- Confirmed ticket-027 is closed on its validated repair line and that the + current split base already carries the relevant behavior. +- Selected `z-ai/glm-5.2` in the ignored local OpenRouter configuration. +- Added deterministic timeout pressure from serialized input, output token + budget, message count, strict JSON Schema and response healing. +- Kept retry backoff inside one adaptive deadline and external cancellation + immediate. +- Persisted the non-secret scaling policy in audits and expanded timeout errors + with base/effective values. +- Passed governance, 349-test verification, gold v2, five SDK examples and + Docker smoke on the validated publication base. diff --git a/project/ticket-034/intent.json b/project/ticket-034/intent.json new file mode 100644 index 0000000..c79a481 --- /dev/null +++ b/project/ticket-034/intent.json @@ -0,0 +1,27 @@ +{ + "schema": "new-project.intent/v2", + "ticket": "ticket-034", + "summary": "Scale each OpenRouter request timeout from bounded input and complexity pressure", + "workstream": "llm", + "allowedPaths": [ + "src/llm/openrouter-timeout.ts", + "src/llm/openrouter.ts", + "src/llm/audit.ts", + "test/openrouter-timeout.test.ts", + "project/ticket-034/**", + "TODO.md", + "project/TICKETS.md" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md", + "src/config/**", + "src/pipeline/**", + "src/extractors/**", + "src/communication/**", + "src/summary/**" + ], + "stacks": ["node", "docker"], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null +} diff --git a/project/ticket-034/preprompt.md b/project/ticket-034/preprompt.md new file mode 100644 index 0000000..550641f --- /dev/null +++ b/project/ticket-034/preprompt.md @@ -0,0 +1,8 @@ +# Ticket preprompt + +- **Task ID**: ticket-034 +- **Task title**: Scale LLM timeout by input complexity +- **Created**: 2026-08-04T12:49:32Z + +Keep executable implementation outside this governance/evidence directory. +Read a human-owned user-*.md file only when one exists. diff --git a/src/llm/audit.ts b/src/llm/audit.ts index b76d0c3..921633f 100644 --- a/src/llm/audit.ts +++ b/src/llm/audit.ts @@ -1,5 +1,6 @@ import type { T2CConfig } from '../config/env.js'; import type { JsonValue } from '../core/types.js'; +import { OPENROUTER_TIMEOUT_POLICY } from './openrouter-timeout.js'; /** Safe, secret-free OpenRouter parameters persisted with standalone and pipeline audits. */ export function openRouterAuditConfiguration( @@ -11,6 +12,15 @@ export function openRouterAuditConfiguration( model, baseUrl: config.openRouter.baseUrl, timeoutMs, + adaptiveTimeout: { + baseTimeoutMs: timeoutMs, + inputCharactersBaseline: OPENROUTER_TIMEOUT_POLICY.inputCharactersBaseline, + outputTokensBaseline: OPENROUTER_TIMEOUT_POLICY.outputTokensBaseline, + complexityPointsBaseline: OPENROUTER_TIMEOUT_POLICY.complexityPointsBaseline, + scaleFactor: OPENROUTER_TIMEOUT_POLICY.scaleFactor, + maximumMultiplier: OPENROUTER_TIMEOUT_POLICY.maximumMultiplier, + maximumTimeoutMs: OPENROUTER_TIMEOUT_POLICY.maximumTimeoutMs, + }, maxTokens: config.openRouter.maxTokens, temperature: config.openRouter.temperature, requireStructuredOutput: config.openRouter.requireStructuredOutput, diff --git a/src/llm/openrouter-timeout.ts b/src/llm/openrouter-timeout.ts new file mode 100644 index 0000000..348b622 --- /dev/null +++ b/src/llm/openrouter-timeout.ts @@ -0,0 +1,136 @@ +export const OPENROUTER_TIMEOUT_POLICY = Object.freeze({ + inputCharactersBaseline: 8_000, + outputTokensBaseline: 6_000, + complexityPointsBaseline: 4, + scaleFactor: 2, + maximumMultiplier: 8, + maximumTimeoutMs: 600_000, +}); + +export interface OpenRouterTimeoutLoad { + serializedInputCharacters: number; + outputTokens: number; + messageCount: number; + strictJsonSchema: boolean; + responseHealing: boolean; +} + +export interface OpenRouterTimeoutDecision extends OpenRouterTimeoutLoad { + baseTimeoutMs: number; + complexityPoints: number; + pressure: number; + multiplier: number; + effectiveTimeoutMs: number; + capped: boolean; +} + +/** Calculate one bounded request deadline without reading environment state. */ +export function calculateOpenRouterTimeout( + baseTimeoutMs: number, + load: OpenRouterTimeoutLoad, +): OpenRouterTimeoutDecision { + assertPositiveFinite(baseTimeoutMs, 'base timeout'); + if (baseTimeoutMs > OPENROUTER_TIMEOUT_POLICY.maximumTimeoutMs) { + throw new Error(`OpenRouter base timeout must not exceed ${OPENROUTER_TIMEOUT_POLICY.maximumTimeoutMs} ms`); + } + assertNonNegativeInteger(load.serializedInputCharacters, 'serialized input characters'); + assertNonNegativeInteger(load.outputTokens, 'output tokens'); + assertNonNegativeInteger(load.messageCount, 'message count'); + if (typeof load.strictJsonSchema !== 'boolean' || typeof load.responseHealing !== 'boolean') { + throw new Error('OpenRouter timeout complexity flags must be boolean'); + } + + const complexityPoints = load.messageCount + + (load.strictJsonSchema ? 2 : 0) + + (load.responseHealing ? 1 : 0); + const pressure = Math.max( + 1, + load.serializedInputCharacters / OPENROUTER_TIMEOUT_POLICY.inputCharactersBaseline, + load.outputTokens / OPENROUTER_TIMEOUT_POLICY.outputTokensBaseline, + complexityPoints / OPENROUTER_TIMEOUT_POLICY.complexityPointsBaseline, + ); + const steps = pressure <= 1 ? 0 : Math.ceil(Math.log2(pressure)); + const multiplier = Math.min( + OPENROUTER_TIMEOUT_POLICY.maximumMultiplier, + OPENROUTER_TIMEOUT_POLICY.scaleFactor ** steps, + ); + const scaledTimeoutMs = baseTimeoutMs * multiplier; + const effectiveTimeoutMs = Math.min( + OPENROUTER_TIMEOUT_POLICY.maximumTimeoutMs, + scaledTimeoutMs, + ); + + return { + ...load, + baseTimeoutMs, + complexityPoints, + pressure, + multiplier, + effectiveTimeoutMs, + capped: effectiveTimeoutMs < scaledTimeoutMs, + }; +} + +/** Derive timeout pressure from the exact JSON-compatible OpenRouter body. */ +export function openRouterRequestTimeout( + body: Record, + baseTimeoutMs: number, +): OpenRouterTimeoutDecision { + let serialized: string; + try { + serialized = JSON.stringify(body); + } catch (error) { + throw new Error(`OpenRouter request body must be JSON-serializable: ${error instanceof Error ? error.message : String(error)}`); + } + if (serialized === undefined) { + throw new Error('OpenRouter request body must serialize to a JSON object'); + } + + const messages = optionalArray(body.messages, 'messages'); + const plugins = optionalArray(body.plugins, 'plugins'); + const responseFormat = optionalObject(body.response_format, 'response_format'); + const jsonSchema = responseFormat?.type === 'json_schema' + ? optionalObject(responseFormat.json_schema, 'response_format.json_schema') + : undefined; + const maxTokens = body.max_tokens === undefined ? 0 : body.max_tokens; + assertNonNegativeInteger(maxTokens, 'max_tokens'); + + return calculateOpenRouterTimeout(baseTimeoutMs, { + serializedInputCharacters: serialized.length, + outputTokens: maxTokens, + messageCount: messages?.length ?? 0, + strictJsonSchema: responseFormat?.type === 'json_schema' && jsonSchema?.strict === true, + responseHealing: plugins?.some((plugin) => { + if (plugin === null || typeof plugin !== 'object' || Array.isArray(plugin)) { + throw new Error('OpenRouter request plugins must contain objects'); + } + return (plugin as Record).id === 'response-healing'; + }) ?? false, + }); +} + +function optionalArray(value: unknown, name: string): unknown[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw new Error(`OpenRouter request ${name} must be an array`); + return value; +} + +function optionalObject(value: unknown, name: string): Record | undefined { + if (value === undefined) return undefined; + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`OpenRouter request ${name} must be an object`); + } + return value as Record; +} + +function assertPositiveFinite(value: number, name: string): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new Error(`OpenRouter ${name} must be a positive finite number`); + } +} + +function assertNonNegativeInteger(value: unknown, name: string): asserts value is number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`OpenRouter ${name} must be a non-negative safe integer`); + } +} diff --git a/src/llm/openrouter.ts b/src/llm/openrouter.ts index 64c1371..252aa16 100644 --- a/src/llm/openrouter.ts +++ b/src/llm/openrouter.ts @@ -1,6 +1,7 @@ import type { T2CConfig } from '../config/env.js'; import type { LlmResponseMetadata } from '../core/types.js'; import { StructuredResponseError, type StructuredSchema } from './structured-schema.js'; +import { openRouterRequestTimeout } from './openrouter-timeout.js'; export interface ChatMessage { role: 'system' | 'user' | 'assistant'; @@ -169,20 +170,23 @@ export class OpenRouterClient { } private async request(body: Record): Promise { - const apiKey = this.config.apiKey; - if (!apiKey) throw new Error('OPENROUTER_API_KEY is required for this operation'); + const configuredCredential = this.config.apiKey; + if (!configuredCredential) throw new Error('OPENROUTER_API_KEY is required for this operation'); + const requestBody = removeUndefined(body) as Record; + const timeoutDecision = openRouterRequestTimeout(requestBody, this.config.timeoutMs); const controller = new AbortController(); const externalSignal = this.config.signal; const abortFromExternal = () => controller.abort(); externalSignal?.addEventListener('abort', abortFromExternal, { once: true }); if (externalSignal?.aborted) controller.abort(); - const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs); + const timeout = setTimeout(() => controller.abort(), timeoutDecision.effectiveTimeoutMs); try { + if (externalSignal?.aborted) throw new Error('OpenRouter request aborted by pipeline deadline'); let lastError: Error | null = null; for (let attempt = 0; attempt < 3; attempt += 1) { try { const headers: Record = { - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${configuredCredential}`, 'Content-Type': 'application/json', 'X-OpenRouter-Title': this.config.appName, }; @@ -190,7 +194,7 @@ export class OpenRouterClient { const response = await fetch(`${this.config.baseUrl}/chat/completions`, { method: 'POST', headers, - body: JSON.stringify(removeUndefined(body)), + body: JSON.stringify(requestBody), signal: controller.signal, }); const text = await response.text(); @@ -205,7 +209,7 @@ export class OpenRouterClient { const error = new Error(`OpenRouter HTTP ${response.status}: ${message}`); if ((response.status === 429 || response.status >= 500) && attempt < 2) { lastError = error; - await sleep(300 * (2 ** attempt)); + await sleep(300 * (2 ** attempt), controller.signal); continue; } if (isInvalidModelError(response.status, message)) { @@ -232,11 +236,14 @@ export class OpenRouterClient { } catch (error) { if (error instanceof Error && error.name === 'AbortError') { if (externalSignal?.aborted) throw new Error('OpenRouter request aborted by pipeline deadline'); - throw new Error(`OpenRouter request timed out after ${this.config.timeoutMs} ms`); + throw new Error( + `OpenRouter request timed out after ${timeoutDecision.effectiveTimeoutMs} ms ` + + `(base ${timeoutDecision.baseTimeoutMs} ms, adaptive ${timeoutDecision.multiplier}x${timeoutDecision.capped ? ', capped' : ''})`, + ); } lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < 2 && /fetch failed|ECONNRESET|ETIMEDOUT/i.test(lastError.message)) { - await sleep(300 * (2 ** attempt)); + await sleep(300 * (2 ** attempt), controller.signal); continue; } throw lastError; @@ -333,6 +340,17 @@ function parseJsonResponse(response: OpenRouterResponse): OpenRouterResult } } -function sleep(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); +function sleep(milliseconds: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(new DOMException('aborted', 'AbortError')); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timeout); + reject(new DOMException('aborted', 'AbortError')); + }; + const timeout = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, milliseconds); + signal.addEventListener('abort', onAbort, { once: true }); + }); } diff --git a/test/openrouter-timeout.test.ts b/test/openrouter-timeout.test.ts new file mode 100644 index 0000000..bbd2b69 --- /dev/null +++ b/test/openrouter-timeout.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { openRouterAuditConfiguration } from '../src/llm/audit.js'; +import { OpenRouterClient } from '../src/llm/openrouter.js'; +import { + calculateOpenRouterTimeout, + OPENROUTER_TIMEOUT_POLICY, + openRouterRequestTimeout, + type OpenRouterTimeoutLoad, +} from '../src/llm/openrouter-timeout.js'; +import { makeConfig } from './helpers.js'; + +const baselineLoad: OpenRouterTimeoutLoad = { + serializedInputCharacters: OPENROUTER_TIMEOUT_POLICY.inputCharactersBaseline, + outputTokens: OPENROUTER_TIMEOUT_POLICY.outputTokensBaseline, + messageCount: OPENROUTER_TIMEOUT_POLICY.complexityPointsBaseline, + strictJsonSchema: false, + responseHealing: false, +}; + +test('adaptive OpenRouter timeout scales at exact power-of-two boundaries', () => { + assert.equal(calculateOpenRouterTimeout(1_000, baselineLoad).effectiveTimeoutMs, 1_000); + assert.equal(calculateOpenRouterTimeout(1_000, { + ...baselineLoad, + serializedInputCharacters: 8_001, + }).effectiveTimeoutMs, 2_000); + assert.equal(calculateOpenRouterTimeout(1_000, { + ...baselineLoad, + serializedInputCharacters: 16_001, + }).effectiveTimeoutMs, 4_000); + assert.equal(calculateOpenRouterTimeout(1_000, { + ...baselineLoad, + serializedInputCharacters: 32_001, + }).effectiveTimeoutMs, 8_000); +}); + +test('output budget and structural complexity independently scale timeout', () => { + const output = calculateOpenRouterTimeout(2_000, { + ...baselineLoad, + serializedInputCharacters: 1, + messageCount: 1, + outputTokens: 6_001, + }); + assert.equal(output.multiplier, 2); + + const structured = openRouterRequestTimeout({ + messages: [{ role: 'system', content: 'a' }, { role: 'user', content: 'b' }], + max_tokens: 1, + response_format: { + type: 'json_schema', + json_schema: { name: 'test', strict: true, schema: { type: 'object' } }, + }, + plugins: [{ id: 'response-healing' }], + }, 2_000); + assert.equal(structured.complexityPoints, 5); + assert.equal(structured.multiplier, 2); + assert.equal(structured.effectiveTimeoutMs, 4_000); +}); + +test('adaptive OpenRouter timeout caps at ten minutes', () => { + const decision = calculateOpenRouterTimeout(120_000, { + ...baselineLoad, + serializedInputCharacters: 32_001, + }); + assert.equal(decision.multiplier, 8); + assert.equal(decision.effectiveTimeoutMs, 600_000); + assert.equal(decision.capped, true); +}); + +test('adaptive OpenRouter timeout rejects malformed and unbounded inputs', () => { + assert.throws( + () => calculateOpenRouterTimeout(Number.POSITIVE_INFINITY, baselineLoad), + /positive finite number/, + ); + assert.throws( + () => calculateOpenRouterTimeout(600_001, baselineLoad), + /must not exceed 600000 ms/, + ); + assert.throws( + () => openRouterRequestTimeout({ messages: 'invalid', max_tokens: 1 }, 1_000), + /messages must be an array/, + ); + assert.throws( + () => openRouterRequestTimeout({ messages: [], max_tokens: Number.NaN }, 1_000), + /max_tokens must be a non-negative safe integer/, + ); +}); + +test('OpenRouter audit records the non-secret adaptive timeout policy', () => { + const config = makeConfig(process.cwd()); + const audit = openRouterAuditConfiguration(config, 'z-ai/glm-5.2'); + assert.deepEqual(audit.adaptiveTimeout, { + baseTimeoutMs: config.openRouter.timeoutMs, + inputCharactersBaseline: 8_000, + outputTokensBaseline: 6_000, + complexityPointsBaseline: 4, + scaleFactor: 2, + maximumMultiplier: 8, + maximumTimeoutMs: 600_000, + }); + assert.equal(JSON.stringify(audit).includes('apiKey'), false); +}); + +test('external cancellation remains immediate before an OpenRouter fetch', async () => { + const config = makeConfig(process.cwd()); + config.openRouter.apiKey = 'test-openrouter-credential'; + const deadline = new AbortController(); + deadline.abort(); + config.openRouter.signal = deadline.signal; + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + throw new Error('fetch should not be called'); + }; + try { + await assert.rejects( + () => new OpenRouterClient(config.openRouter).chatText([{ role: 'user', content: 'test' }]), + /aborted by pipeline deadline/, + ); + assert.equal(calls, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('retry backoff remains inside one effective OpenRouter deadline', async () => { + const config = makeConfig(process.cwd()); + config.openRouter.apiKey = 'test-openrouter-credential'; + config.openRouter.timeoutMs = 10; + config.openRouter.maxTokens = 6_001; + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response(JSON.stringify({ error: { message: 'retry later' } }), { + status: 429, + headers: { 'Content-Type': 'application/json' }, + }); + }; + try { + await assert.rejects( + () => new OpenRouterClient(config.openRouter).chatText([{ role: 'user', content: 'test' }]), + /timed out after 20 ms \(base 10 ms, adaptive 2x\)/, + ); + assert.equal(calls, 1); + } finally { + globalThis.fetch = originalFetch; + } +});