1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864import { readFileSync, readdirSync } from "node:fs";
import { basename, resolve } from "node:path";
import { parse } from "yaml";
// Type-only: config decides what `trust proxy` becomes, and applyTrustProxy below has to
// hand it to the app to find out whether Express will take it.
import type { Express } from "express";
export type Capability = "text" | "vision" | "structured_output";
export interface ProviderBlock {
api_key?: string;
base_url?: string;
region?: string;
default_model: string;
per_capability?: Partial<Record<Capability, string>>;
// Output-token ceiling for this provider's calls. A dense page of accessible
// HTML is the binding case: hit the ceiling and the model stops mid-tag.
// Normalized by loadConfig, so providers can trust it.
max_tokens?: number;
// OPTIONAL override for what one uploaded page image may be. Leave unset: the
// defaults come from providers/imageLimits.ts, which derives them from the model
// and provider this block configures, so they follow a model switch on their own.
// Set one only for something that table cannot know โ a model released after it
// was written, or a platform with its own cap. Normalized where it is read, not
// here, because the fallback is a per-model lookup rather than a constant.
image_limits?: {
// Bytes on disk. Defaults to three quarters of max_base64_bytes (base64 is 4
// characters per 3 bytes).
max_image_bytes?: number;
// The provider's cap on base64-encoded image data in one request.
max_base64_bytes?: number;
// Long edge above which the model downscales. Published, never enforced.
max_long_edge_px?: number;
};
// Whether this provider's calls may ask the model to cache the prompt prefix they
// re-send (providers/promptCache.ts). Defaults to true, and should stay that way:
// it makes the same run cheaper and changes nothing about what the model returns.
// Set false only for an upstream that REJECTS `cache_control` โ a request the
// upstream refuses is not a slow call, it is every call โ which is a real risk only
// behind a broker that chooses the upstream for us. Normalized where it is read.
prompt_cache?: boolean;
// How long those cache entries should live: `5m` (the default) or `1h`. A question
// about this deployment's CADENCE and nothing else โ see providers/promptCache.ts
// `CacheTtl` for the arithmetic. Within one run the default never expires, because
// every page call reads the same prefix and each read refreshes it; an hour is worth
// asking for only where runs arrive in bursts more than five minutes and less than an
// hour apart. Anything unrecognized reads as the default. Normalized where it is read.
prompt_cache_ttl?: string;
// BEDROCK ONLY, and off by default. Which Bedrock API the calls go out on: `invoke`
// (the default โ `InvokeModelWithResponseStream` with an Anthropic-native body, which
// is what every deployment has always sent) or `converse` (`ConverseStream`, whose
// request shape belongs to Bedrock rather than to a model vendor and therefore reaches
// a non-Claude model at all). See providers/bedrock.ts `bedrockApi`: parity between the
// two is an empirical question about a live endpoint, so this exists to be measured
// with. Anything unrecognized is the default, and is warned about at boot.
api?: string;
}
// How much one caller may ask of this deployment per minute, and how many uploads it
// will receive at once. Enforced in util/requestLimits.ts and published by
// `GET /v1/limits`; see that module for why each default is the size it is.
export interface RateLimitConfig {
// Off switch for a deployment that limits request volume somewhere in front of Iris
// and would rather not have two budgets to reason about. Not the way to widen a
// limit โ raise the numbers for that; this removes the bound entirely.
enabled: boolean;
// Requests per minute across all of `/v1`, per address.
general_per_minute: number;
// Session creations per minute, per address.
upload_per_minute: number;
// Megabytes of upload body being received at once, across all callers. A rate limit
// counts requests over a window; this bounds the ones that OVERLAP, which is what
// upload memory is actually spent by โ multer buffers every part in memory.
max_upload_memory_mb: number;
}
export interface IrisConfig {
server: {
port: number;
base_url: string;
// Plain shared secret โ not a GitHub credential โ that gates `GET /v1/quality`, the
// deployment-wide quality tally the weekly workflow files issues from. Unset by
// default, and the endpoint answers 404 until it is set: an operator opts in rather
// than discovering they exposed it.
//
// Separate from `api_token` below, which gates the rest of `/v1`, for two reasons
// that outlive either key. THE CANONICAL EXPLANATION โ other sites point here.
//
// - Different reader. The caller is the weekly CI job, and the only thing it needs
// is a page tally. Handing it `api_token` would hand a scheduled workflow the
// secret that opens every session's document to get an aggregate.
// - Different exposure. Unset, this answers 404 rather than 401, so a deployment
// that never opted in does not admit the endpoint exists. `api_token` cannot do
// that: a gate has to say it is a gate.
//
// It also has to answer ON a gated deployment โ the CI job holds this token and not
// the other one โ which is why it carries its own guard instead of relying on the gate.
// What delivers that is index.ts never handing `/v1/quality` to the auth middleware.
quality_token?: string;
// OPTIONAL shared secret that gates `/v1/me` and `/v1/sessions`. Unset by default, which
// leaves the deployment open to anyone who can reach it โ that is what a public demo
// needs, and the per-address limits in util/requestLimits.ts are what bound the cost.
//
// It is not the whole of `/v1`: index.ts attaches the auth middleware to those two mounts
// and to nothing else, so `/v1/health`, `/v1/stats`, `/v1/limits` and `/v1/quality` answer
// whether this is set or not. Each of those says on its own mount why it may.
//
// Set it and a call to either mount must send `Authorization: Bearer <it>`. Two things
// follow, and both are the point rather than a limitation:
//
// - It is NOT a GitHub token, so a copy that leaks costs a rate-limit bypass rather
// than write access to `github.upstream_repo`. Same reasoning as `quality_token`
// above, which is where this pattern comes from.
// - The demo page cannot use a deployment that sets it. The page holds no
// credential by design, so a gated deployment is an API-only one.
//
// It answers "may this caller use the API", never "who is this caller" โ that question
// has one answer now (`github.token`), and this key does not change it.
api_token?: string;
// How many reverse proxies sit in front of this deployment, as a hop count (1 for a
// single Caddy or nginx). Passed to Express's `trust proxy` setting, which is what
// decides whether `req.ip` is the caller's address or the proxy's.
//
// It is a rate-limiting knob before it is anything else: unset behind a proxy, every
// caller presents as the same address and the per-address limits treat the whole
// deployment as one client. Left off by default because the opposite mistake is
// worse โ trusting a hop that does not exist means believing an `X-Forwarded-For`
// the client wrote, which turns a per-address limit into no limit at all. A hop
// count is the safe form: with 1, only the address the proxy appended is read, and
// whatever the client claimed before it is ignored.
//
// Also accepts an Express-recognized string (`loopback`, a subnet) for a deployment
// whose topology a count cannot describe. Boolean `true` is coerced to 1 with a
// warning (trustProxyWarning), and a string Express cannot compile is refused with one
// rather than crashing the process at startup (applyTrustProxy).
trust_proxy?: number | boolean | string;
// Optional overrides for the request-volume budget. Normalized by loadConfig, and
// resolved through resolveRateLimits at every read, so a config that omits the block
// (or a hand-built one in a test) still gets a complete, usable set.
rate_limits?: Partial<RateLimitConfig>;
};
storage: { data_dir: string; agents_dir: string; database: string };
github: {
upstream_repo: string;
// Overridable for GitHub Enterprise (and for testing). Defaults below.
api_base_url: string; // e.g. https://api.github.com
// The deployment's ONE GitHub identity: a PAT the operator sets, held server-side,
// used for every call Iris makes to GitHub and resolved once per process to the
// account that owns every session.
//
// Iris used to authenticate each visitor separately, through a GitHub device flow,
// so an issue it opened was filed under the person who ran the session. That is
// gone. Three credential concepts (per-user tokens, a service PAT for filing, and
// an anonymous PAT for visitors who sent nothing) have collapsed into this one,
// because two of them existed only to bridge between the other two.
//
// What that costs, stated here because it is invisible from outside:
//
// - Every issue Iris opens is filed under this account, so a contributor gets no
// attribution for the agent they caused to be suggested.
// - Every session has the same owner, so `GET /v1/sessions` lists the
// deployment's sessions rather than one visitor's, and one visitor can read
// another's document by its id. A deployment where that matters wants
// `server.api_token` set and no public page.
// - Uploads are counted per ADDRESS, since a shared identity is one bucket for
// everyone (util/requestLimits.ts).
//
// Never send this to a browser. It carries `issues: write` on `upstream_repo`, so a
// page that passed it to callers would be publishing it; the demo page sends no
// credential at all and the server supplies this one.
token?: string;
};
providers: {
default: string;
// Per-agent override. A string is shorthand for a provider name (model then
// comes from that provider's per_capability/default_model). The object form
// also allows pinning a specific model for that agent.
per_agent?: Record<string, string | { provider?: string; model?: string }>;
openrouter?: ProviderBlock;
bedrock?: ProviderBlock;
[key: string]: unknown;
};
defaults: {
// Reader/editor rounds a document gets before the review loop stops and lists
// what is left in `unresolved.md`. Read once, at first auth, to seed the
// account default every session inherits โ a session cannot override it.
// Normalized by loadConfig, so it is always >= 1.
max_review_iterations: number;
// How many pages to extract in parallel within one run. Pages are
// independent (one vision call each), so this is a pure speed knob; it is
// capped to keep a burst of concurrent calls from tripping provider rate
// limits. Normalized by loadConfig, so it is always a valid integer.
extraction_concurrency: number;
// How many pipeline runs execute at once ACROSS sessions. Unlike
// extraction_concurrency (within one run), this bounds what the machine as a
// whole is doing: each run holds jsdom+axe and up to extraction_concurrency
// model calls, so the real peak is the product of the two. Runs over the cap
// wait in `queued`; they are not rejected. Normalized by loadConfig.
max_concurrent_runs: number;
// How many corrected pages a run re-verifies for MEASUREMENT ONLY โ the sample
// behind `verification.rechecks.sampled` (pipeline/correction.ts
// `recheckSampler`). Decides nothing about the delivered document at any
// value. `0` turns the measurement off; a value at or above the run's page count
// re-verifies every corrected page, which is the only setting whose result is a
// rate over corrected pages rather than over whichever pages the slots landed on.
// Normalized by loadConfig, so it is always an integer >= 0.
recheck_sample_size: number;
};
}
// Pages extracted in parallel when the deployment doesn't say. Modest on purpose:
// a burst of concurrent calls is the most likely way to trip a provider's rate
// limit, and while both adapters retry throttling responses (OpenRouter by hand,
// Bedrock via the AWS SDK's standard strategy), backing off repeatedly is slower
// than not being throttled in the first place.
export const DEFAULT_EXTRACTION_CONCURRENCY = 5;
// Upper bound. Past this, added parallelism buys little (provider-side rate
// limits dominate) and each in-flight call holds a base64 page image in memory.
export const MAX_EXTRACTION_CONCURRENCY = 16;
// Pipeline runs allowed to execute simultaneously when the deployment doesn't
// say. Deliberately small: 2 concurrent runs on a default config already means
// up to 10 in-flight vision calls (2 ร DEFAULT_EXTRACTION_CONCURRENCY) plus two
// live jsdom+axe instances, on a machine that may be a laptop (README, "One machine,
// no vendor lock-in").
// The knob to reach for first on a bigger box is this one, not the extraction
// concurrency โ waiting is cheap and visible (`status: "queued"`), whereas
// over-subscribing degrades every run at once.
export const DEFAULT_MAX_CONCURRENT_RUNS = 2;
// Corrected pages a run re-verifies for measurement only, when the deployment
// doesn't say. One, because the sample decides nothing about the document and its
// cost is the thing under investigation: re-verifying every corrected page adds a
// Feedback Agent call per correction, and on the two 100-page rounds behind issue
// #288 that is 111 extra calls for 200 pages โ verify is already 14.2% of a
// document's bill (issue #280), so a census on a run where most pages are corrected
// adds roughly half that again.
//
// What one draw per run cannot do is produce a RATE, and for four rounds it was read
// as one. `recheckSampler` gets one slot for a whole run, so a 25-page batch supplies
// a single verdict and a 100-page document supplied four: on `runs-extract100-1`,
// 111 corrections were bought and 8 were measured (7.2%), 3 of them ok โ and the two
// models' four-draw samples read 50% and 25% off the same corpus. The census that
// answered it (issue #288: 26% of corrected pages clear the verifier, against a 2%
// floor from re-asking about the uncorrected page, n=57, sign test p=0.000) had to be
// replayed off persisted replies in the bench, because this knob did not exist.
//
// So the number stays 1 โ a deployment keeps accumulating a count at ~1% of a
// document โ and the knob is what makes the rate buyable when somebody wants it.
export const DEFAULT_RECHECK_SAMPLE_SIZE = 1;
// Upper bound. This is a cap on how much of the machine one deployment will
// commit, not a statement about what a provider will serve; an operator who
// genuinely needs more concurrency than this wants multiple instances and a
// shared Postgres store, which v1 does not implement.
export const MAX_CONCURRENT_RUNS_CEILING = 32;
// The request-volume budget when the deployment doesn't say. Each is per minute; see
// util/requestLimits.ts for the traffic each one was sized against, which is the thing
// to re-check before changing one. In short: the demo page polls a running session every
// 2.5s (24/min) and every caller behind one NAT shares a bucket, so these sit an order of
// magnitude above a working client and still bound a loop that has come off its leash.
export const DEFAULT_GENERAL_PER_MINUTE = 240;
export const DEFAULT_UPLOAD_PER_MINUTE = 12;
// Upload body being received at once, in megabytes โ the only one of these that bounds
// MEMORY rather than request count, and so the first to reduce on a small machine. 256 MB
// is two uploads at the 128 MB per-request ceiling (util/requestLimits.ts), or any number
// of ordinary ones: a full 25-page session of images is ~92 MB and a typical one a few MB,
// so this refuses concurrent uploads only when they are genuinely large.
export const DEFAULT_MAX_UPLOAD_MEMORY_MB = 256;
// Coerce one configured limit into a usable positive integer. Same "absent means the
// default, not zero" trap as the concurrency normalizers, and here the consequence of
// letting a 0 through is the most severe of the set: express-rate-limit reads a limit of
// 0 as "allow nothing", so a valueless `general_per_minute:` in YAML (null, and
// Number(null) is 0) would answer 429 to every request the deployment ever receives. A
// non-positive value is therefore treated as unset rather than obeyed; `enabled: false`
// is how a deployment turns limiting off, which says so in one place instead of being
// inferred from a number.
function normalizePositiveInt(value: unknown, fallback: number): number {
if (value === null || value === undefined) return fallback;
if (typeof value === "string" && value.trim() === "") return fallback;
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
return n < 1 ? fallback : Math.floor(n);
}
/**
* The complete request-volume budget for a config that may specify none, some, or all of
* it. Idempotent, and deliberately called at every read (util/requestLimits.ts,
* routes/limits.ts) as well as once by loadConfig: `server.rate_limits` is optional, so
* the alternative is every consumer carrying its own fallback and one of them eventually
* disagreeing about what the default is.
*
* Only an explicit `false` disables limiting. Anything else โ including a config that
* says nothing at all โ is on, because the failure mode of a typo'd key silently
* removing the bound is exactly the state this feature exists to prevent.
*/
export function resolveRateLimits(raw: Partial<RateLimitConfig> | undefined): RateLimitConfig {
const enabled = !(raw?.enabled === false || String(raw?.enabled) === "false");
return {
enabled,
general_per_minute: normalizePositiveInt(raw?.general_per_minute, DEFAULT_GENERAL_PER_MINUTE),
upload_per_minute: normalizePositiveInt(raw?.upload_per_minute, DEFAULT_UPLOAD_PER_MINUTE),
max_upload_memory_mb: normalizePositiveInt(raw?.max_upload_memory_mb, DEFAULT_MAX_UPLOAD_MEMORY_MB),
};
}
/**
* Coerce a configured `trust_proxy` into the shape Express's `trust proxy` setting takes.
*
* Absent, empty, `false`, `"false"`, `0` -> false (trust nothing, the safe default).
* A number or numeric string -> that many hops. Any other string is passed through for
* Express to interpret (`loopback`, a subnet list) โ whether it CAN interpret it is
* applyTrustProxy's business, not this function's. `true` becomes 1, because trusting every
* hop means believing an `X-Forwarded-For` the client wrote โ see trustProxyWarning, which
* says so out loud rather than silently obeying or silently correcting.
*/
export function normalizeTrustProxy(value: unknown): number | boolean | string {
if (value === null || value === undefined) return false;
if (typeof value === "boolean") return value ? 1 : false;
if (typeof value === "number") return Number.isFinite(value) && value > 0 ? Math.floor(value) : false;
const text = String(value).trim();
if (text === "" || text.toLowerCase() === "false") return false;
if (text.toLowerCase() === "true") return 1;
const n = Number(text);
if (Number.isFinite(n)) return n > 0 ? Math.floor(n) : false;
return text;
}
// A boot-time warning for the one `trust_proxy` value that is accepted and unsafe.
// Returned rather than logged, like the two GitHub warnings above, so it is testable and
// the caller decides where it goes.
export function trustProxyWarning(value: unknown): string | undefined {
if (value !== true && String(value).trim().toLowerCase() !== "true") return undefined;
return (
`server.trust_proxy is "true", which tells Express to trust the whole X-Forwarded-For chain โ ` +
`including the part a client wrote, so any caller could present a fresh address per request and ` +
`the per-address rate limits would bound nothing. Using 1 hop instead. Set it to the number of ` +
`proxies actually in front of this deployment.`
);
}
/**
* Put a configured `trust_proxy` on an Express app, and hand back the warning it earned.
*
* Express compiles this setting EAGERLY through proxy-addr and throws on anything proxy-addr
* cannot parse, so a typo in this one key used to take the process down at startup with a
* `TypeError: invalid IP address: yes` naming neither the key nor the file it came from.
* Every other unusable value in this file warns and falls back to a working default; this
* one now does the same.
*
* Validated by ATTEMPTING it rather than by checking the string against a copy of
* proxy-addr's grammar, because that grammar has corners a copy gets wrong: the three names
* are case-sensitive (`Loopback` is an error), `/0` is not a valid range, an IPv4 mask must
* be contiguous. A second implementation of it would be a second thing to disagree with
* Express โ and it would disagree in the direction that crashes, since a value it wrongly
* approved is a value Express refuses. What Express accepts is what Express accepts.
*/
export function applyTrustProxy(app: Pick<Express, "set">, value: unknown): string | undefined {
try {
app.set("trust proxy", normalizeTrustProxy(value));
return trustProxyWarning(value);
} catch {
app.set("trust proxy", false);
return (
`server.trust_proxy is ${JSON.stringify(String(value).trim())}, which Express cannot interpret. It ` +
`takes a number of proxies in front of this deployment (1 behind a single Caddy/nginx), one of ` +
`"loopback", "linklocal" or "uniquelocal" (lower case), or a comma-separated list of proxy ` +
`addresses and subnets. Trusting nothing instead, which means every caller behind a proxy shares ` +
`one rate-limit bucket.`
);
}
}
// Reader/editor rounds a document gets when the deployment doesn't say. Since the
// per-request override was removed, this config value is the ONLY
// input to the cap โ it seeds every account default on first auth โ so the values
// it must not silently become are the ones that would quietly stop the review loop
// from reviewing: `0` buys one reader pass with no fix ever applied, and a negative
// skips review outright (src/pipeline/review.ts). Both are floored to 1 by the
// normalizer below.
export const DEFAULT_MAX_REVIEW_ITERATIONS = 3;
// Output-token ceiling when a provider block doesn't set one. 8192 was the old
// hardcoded Bedrock value and is comfortably too small for a dense page: a
// full-page table or form of accessible HTML can exceed it, and the model then
// stops mid-tag. Raised to a value current Claude models all accept, because the
// failure it prevents (silently truncated HTML flowing downstream as if valid) is
// far worse than the cost of a ceiling that is rarely reached โ output is billed
// per token emitted, not per token allowed.
export const DEFAULT_MAX_TOKENS = 32_000;
// Recursively expand ${ENV_VAR} references against process.env, recording which
// variables resolved to nothing. An unset variable still expands to "" (a config
// may legitimately reference a provider it doesn't use), but the names are kept
// so validateConfig can name the likely cause of an empty required field.
function expandEnv(value: unknown, unset: Set<string>): unknown {
if (typeof value === "string") {
return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name: string) => {
const found = process.env[name];
if (found === undefined || found === "") {
unset.add(name);
return "";
}
return found;
});
}
if (Array.isArray(value)) return value.map((v) => expandEnv(v, unset));
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = expandEnv(v, unset);
return out;
}
return value;
}
// Fail at startup on config that cannot work, instead of surfacing it as a
// confusing mid-pipeline error (an unset ${OPENROUTER_API_KEY} used to expand to
// "" and reappear later as a 401 partway through a run).
//
// Only providers this deployment can actually reach are checked โ `default` plus
// anything named in `per_agent` โ so a config that carries an unused second
// provider block stays valid without its credentials.
function validateConfig(cfg: IrisConfig, unset: Set<string>, path: string): void {
const problems: string[] = [];
// The GitHub App validation this function used to do is gone with the device flow it
// guarded. It checked `client_id`'s prefix, because an OAuth App id there produced a
// deployment where every login worked and no issue could ever be filed โ a failure
// invisible at boot and silent at runtime. There is no `client_id` now: Iris holds one
// PAT and authenticates as one account.
//
// What replaces it is a plain presence check. The whole design rests on this key, and
// an unset `${IRIS_GITHUB_TOKEN}` expands to `""` rather than disappearing (see
// `expandEnv`), so the shape to catch is a key that is present and empty โ which is
// exactly what an operator who forgot the environment variable produces. Fatal rather
// than warned, unlike the old check: without it there is no account to own a session,
// so every request to `/v1/me` or `/v1/sessions` would answer `500 server_error:
// github.token is not configured` (auth/middleware.ts) โ nothing could convert a
// document or read one, which is the whole service.
if (!githubToken(cfg)) {
problems.push(
`github.token is not set. Iris needs one GitHub PAT, held by the server, to own every session and file ` +
`every issue: create a fine-grained token with issues:write on github.upstream_repo and set it (the ` +
`example config reads it from IRIS_GITHUB_TOKEN). It is never sent to a browser.`,
);
}
if (!cfg.providers?.default) problems.push("providers.default is not set");
const referenced = new Set<string>([cfg.providers.default]);
for (const entry of Object.values(cfg.providers.per_agent ?? {})) {
const name = typeof entry === "string" ? entry : entry?.provider;
if (name) referenced.add(name);
}
for (const name of referenced) {
if (!name) continue;
const block = cfg.providers[name] as ProviderBlock | undefined;
if (!block) {
problems.push(`providers.${name} is referenced but has no configuration block`);
continue;
}
if (!block.default_model) problems.push(`providers.${name}.default_model is not set`);
// Bedrock authenticates through the standard AWS credential chain, so there
// is no key in config to check.
if (name === "openrouter" && !block.api_key) {
problems.push(`providers.openrouter.api_key is empty (set OPENROUTER_API_KEY)`);
}
}
if (problems.length === 0) return;
const hint = unset.size > 0 ? ` Unset environment variables: ${[...unset].sort().join(", ")}.` : "";
throw new Error(`Invalid config ${path}:\n - ${problems.join("\n - ")}\n${hint}`);
}
// A boot-time warning for a `prompt_cache_ttl` that is set to something this does not
// recognize (providers/promptCache.ts `promptCacheTtl`).
//
// This one has to be said out loud because it is the only place it CAN be. An
// unrecognized value is ignored, and the two TTLs differ in what they are billed at
// rather than in what they report: the same prefix written either way produces the same
// `cache_creation_input_tokens`, so no diagnostics field distinguishes them and an
// operator who typed `60m` would go on believing they had bought the hour. Every other
// unusable value in this config already says so at startup.
//
// It warns for a block with `prompt_cache: false` too, where no entry is written at all
// and neither TTL is in play. That is deliberate โ the value is still one nobody can
// use, and it would become live the day caching is turned back on โ so the wording says
// the value is ignored rather than naming a TTL that block does not have.
//
// Returns one warning naming every provider block that has one, since a deployment
// configures more than one and fixing the first would otherwise hide the second.
export function promptCacheTtlWarning(providers: IrisConfig["providers"]): string | undefined {
const bad: string[] = [];
for (const [name, block] of Object.entries(providers)) {
// `default` is a string and `per_agent` is a map of agent overrides; neither is a
// provider block. Skipped BY NAME, the way loadConfig's own pass over this object
// does it, rather than by shape: `per_agent` is an object and would otherwise reach
// the lookup below and be searched for a key belonging to a provider.
if (name === "default" || name === "per_agent") continue;
if (!block || typeof block !== "object" || Array.isArray(block)) continue;
const v = (block as ProviderBlock).prompt_cache_ttl as unknown;
if (v === null || v === undefined) continue;
const text = String(v).trim();
// An empty value is an operator who set nothing, which is what the default is for.
if (text === "") continue;
const normalized = text.toLowerCase();
if (normalized !== "5m" && normalized !== "1h") bad.push(`${name}: "${text}"`);
}
if (bad.length === 0) return undefined;
return (
`providers.${bad.join(", providers.")} โ prompt_cache_ttl must be "5m" or "1h". ` +
`This one is ignored. Nothing downstream will report that: the two TTLs differ in ` +
`what a cache write is BILLED at (1.25x against 2x), not in the token counts ` +
`diagnostics publishes, so a deployment that meant to hold its cache for an hour ` +
`would never find out from Iris.`
);
}
// The same kind of warning for `api`, which picks the Bedrock API a block's calls go out
// on (providers/bedrock.ts `bedrockApi`).
//
// Same asymmetry as the TTL above and the same treatment: an unrecognized value falls back
// to `invoke`, which is the path that works, and nothing downstream can tell you it did.
// Both APIs return the same text through the same interface, so an operator who wrote
// `Converse` or `converse-stream` and set out to measure the new path against the old
// would be measuring the old path twice.
//
// Named blocks rather than a bare complaint, and only Bedrock blocks: the field is
// meaningless to the OpenRouter adapter, and warning about it there would send an operator
// looking for a setting that does not exist. Which blocks are Bedrock is decided by the
// provider NAME, the same way loadConfig decides which adapter to construct.
export function bedrockApiWarning(providers: IrisConfig["providers"]): string | undefined {
const bad: string[] = [];
for (const [name, block] of Object.entries(providers)) {
if (name !== "bedrock") continue;
if (!block || typeof block !== "object" || Array.isArray(block)) continue;
const v = (block as ProviderBlock).api as unknown;
if (v === null || v === undefined) continue;
const text = String(v).trim();
// An empty value is an operator who set nothing, which is what the default is for.
if (text === "") continue;
const normalized = text.toLowerCase();
if (normalized !== "invoke" && normalized !== "converse") bad.push(`${name}: "${text}"`);
}
if (bad.length === 0) return undefined;
return (
`providers.${bad.join(", providers.")} โ api must be "invoke" or "converse". ` +
`This one is ignored and the calls go out on "invoke", the Anthropic-native path. ` +
`Nothing downstream will report that: both APIs return the same text through the same ` +
`interface, so a deployment that meant to be testing ConverseStream would be measuring ` +
`the path it already had.`
);
}
// The agents `providers.per_agent` can actually route, as literals, because config cannot
// read the call sites that dispatch them. Each one is the first argument of a
// `router.complete` call: `page` (extraction's extract, correct and specialist-merge
// passes), `reader` and `copy_editor` (the review round, and the table join, which shares
// the copy editor's entry), `feedback` (every verify, recheck and feedback-round call) and
// `builder` (drafting a specialist agent for a contribution).
//
// A hand list in a file that cannot check itself, so test/config-agents.test.ts derives the
// same set from the `router.complete` call sites in src/ and fails if the two disagree.
// That is the coupling worth pinning: the list going stale is how the examples this warning
// exists to catch went wrong in the first place.
const DISPATCHED_AGENTS = ["page", "reader", "copy_editor", "feedback", "builder"] as const;
// A boot-time warning for a `providers.per_agent` key that names no agent Iris dispatches.
//
// The same asymmetry as the two warnings above, and the most expensive instance of it,
// because this key is the whole model-selection surface: an unrecognized agent name is
// ignored and every call falls through the ordinary chain to the provider's own model, so
// an operator who wrote one gets the deployment they already had. The run costs and succeeds,
// and this warning is the only thing that names the key: `by_agent` reports the agents that
// ran and never the override that matched none of them, so a key nobody reads is invisible
// there. What it does now report is `models` per agent, which is the other half of the same
// question and answers it from the other end โ the failure is a model that was not swapped,
// and the row for that agent names the model it actually ran on. Warning at boot is still
// worth more than that: it says which key is wrong, before the run is paid for.
//
// It is how `config.example.yaml` came to invite `table:` and the retired specification to
// show `image_analysis:` โ both relics of the per-content-type fan-out that was withdrawn,
// and stale in different ways: `image_analysis` was the triage agent and went
// with `pipeline/triage.ts`, while `table` was never dispatched by anything. The table
// join in particular reads as though it has its own line, and it does not: it is a
// `copy_editor` call (pipeline/tables.ts), so the join and the review round cannot be put
// on different models. An example is where an operator starts, and both were no-ops.
//
// Warned rather than refused, and the reason is the specialist path: `loadAgent` takes an
// agent's name from the basename of any `.md` in `agents_dir`, which is a git checkout
// that gains files from upstream, and extraction dispatches one by name off a page's
// `suggested_agent`. So the set of valid keys is open, and this reads the directory to
// find today's members rather than pretending five names is all of them. An unreadable
// or unconfigured `agents_dir` warns about nothing: the directory is the more likely
// thing to be wrong, and a warning naming every agent as unknown would be noise.
export function perAgentKeyWarning(
perAgent: IrisConfig["providers"]["per_agent"],
agentsDir: string,
): string | undefined {
const keys = Object.keys(perAgent ?? {});
if (keys.length === 0) return undefined;
const known = new Set<string>(DISPATCHED_AGENTS);
let library: string[] = [];
try {
library = readdirSync(agentsDir)
.filter((f) => f.endsWith(".md"))
.map((f) => basename(f, ".md"));
} catch {
return undefined; // no readable agents dir โ see above
}
for (const name of library) known.add(name);
const unknown = keys.filter((k) => !known.has(k));
if (unknown.length === 0) return undefined;
// The two routable sets are named separately rather than as one list with a tail clause,
// because the whole question this sentence answers is whether a name came from the code or
// from a directory the operator controls โ and any single list has to be sorted, which puts
// a specialist wherever its name happens to fall.
const dispatched = new Set<string>(DISPATCHED_AGENTS);
const specialists = [...new Set(library.filter((n) => !dispatched.has(n)))].sort();
const where = `, and any agent file in ${agentsDir} (${
specialists.length > 0 ? `currently ${specialists.join(", ")}` : "which currently holds none"
})`;
return (
`providers.per_agent names ${unknown.map((k) => `"${k}"`).join(", ")}, which Iris does not ` +
`dispatch. Those entries are ignored: the calls fall through to the provider's own model, so ` +
`the swap they were written for does not happen and no log line reports that it did not. ` +
`The agents this deployment can route are ${DISPATCHED_AGENTS.join(", ")}${where}.` +
// Only where it answers the key in hand. Said unconditionally it is a sentence about
// tables attached to a typo.
(unknown.includes("table")
? ` There is no table agent: joining a table split across a page break is a copy_editor ` +
`call, and it shares that agent's entry with the review round.`
: "")
);
}
/**
* The deployment's one GitHub credential, or undefined when it is unset.
*
* One function because several callers have to agree on what "set" means, and an unset
* `${IRIS_GITHUB_TOKEN}` expands to `""` rather than disappearing (see `expandEnv`), so
* "present in the YAML" is not the test. Whitespace is trimmed for the same reason: the
* auth middleware would reject `" "` as a credential, and a config that looked set while
* every request 401s sends an operator looking in the wrong half of the system.
*/
export function githubToken(cfg: IrisConfig): string | undefined {
return cfg.github.token?.trim() || undefined;
}
/**
* The shared secret gating `/v1`, or undefined when the deployment is open.
*
* Same trimming rule and the same reason as `githubToken` above: `" "` would be a gate
* no caller could satisfy, since the middleware compares against a trimmed header.
*/
export function apiToken(cfg: IrisConfig): string | undefined {
return cfg.server.api_token?.trim() || undefined;
}
// A boot-time line stating what this deployment's single identity means, or undefined
// when `github.token` is unset (validateConfig refuses that, so this is only for a
// hand-built config in a test).
//
// Not a complaint: the operator set the key, and this is a statement of what it decided
// for them. It exists because every consequence is invisible from outside โ the service
// answers 200 to callers with no credential at all, which is the intended behaviour and
// is also indistinguishable from a private deployment right up to the point where a
// stranger's document is involved.
//
// Printed at every boot rather than once at first use, because "who does this deployment
// think its users are" is a property an operator reads in the log; the alternative place
// to notice it is a visitor's document appearing in someone else's session list.
//
// Deliberately does NOT name the credential, not even truncated: it is a live GitHub token
// for a real account, and a boot log gets copied into issues.
export function identityWarning(token: string | undefined, gated: boolean): string | undefined {
if (!token) return undefined;
return (
`github.token is this deployment's only identity: every session is owned by that one account and every ` +
`issue Iris opens is filed under it, so contributors get no attribution and GET /v1/sessions lists the ` +
`deployment's sessions rather than one caller's. Uploads are therefore rate limited by address, not per ` +
`user. ` +
(gated
? `server.api_token is set, so callers must present it โ the demo page cannot be used against this ` +
`deployment, because it holds no credential.`
: `server.api_token is unset, so anyone who can reach this deployment can convert documents and read ` +
`any session whose id they have. Set server.api_token to require a shared secret.`)
);
}
// Coerce a configured max_tokens into a usable integer. Same "absent means the
// default, not zero" trap as normalizeConcurrency: YAML parses a valueless
// `max_tokens:` as null, and Number(null) is 0 โ which would cap every call at
// zero output tokens and make every response empty. There is no upper clamp; the
// provider rejects a value its model won't accept, and that error names the real
// limit better than a guess compiled in here would. Exported for tests.
export function normalizeMaxTokens(value: unknown): number {
if (value === null || value === undefined) return DEFAULT_MAX_TOKENS;
if (typeof value === "string" && value.trim() === "") return DEFAULT_MAX_TOKENS;
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return DEFAULT_MAX_TOKENS;
// A configured 0 or negative is meaningless for an output ceiling (and would
// silently empty every response), so treat it as "unset" rather than obeying it.
return n < 1 ? DEFAULT_MAX_TOKENS : Math.floor(n);
}
// Coerce a configured max_concurrent_runs into a usable integer. Same
// "absent means the default, not zero" trap as the other two normalizers, but
// with a worse consequence if it slipped through: a limit of 0 would leave the
// queue accepting every session and starting none, so uploads would sit in
// `queued` forever with nothing logged to say why. Clamped to
// [1, MAX_CONCURRENT_RUNS_CEILING]. Exported for tests.
export function normalizeMaxConcurrentRuns(value: unknown): number {
if (value === null || value === undefined) return DEFAULT_MAX_CONCURRENT_RUNS;
if (typeof value === "string" && value.trim() === "") return DEFAULT_MAX_CONCURRENT_RUNS;
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return DEFAULT_MAX_CONCURRENT_RUNS;
return Math.min(MAX_CONCURRENT_RUNS_CEILING, Math.max(1, Math.floor(n)));
}
// Coerce a configured extraction_concurrency into a usable integer: missing or
// non-numeric falls back to the default, and anything valid is clamped to
// [1, MAX_EXTRACTION_CONCURRENCY]. Exported for tests.
export function normalizeConcurrency(value: unknown): number {
// "Not specified" must mean the default, not 1. Guard null/""/whitespace up
// front: YAML parses a valueless `extraction_concurrency:` as null, and
// Number(null) is 0 โ which is finite, so it would otherwise clamp to 1 and
// silently disable parallelism.
if (value === null || value === undefined) return DEFAULT_EXTRACTION_CONCURRENCY;
if (typeof value === "string" && value.trim() === "") return DEFAULT_EXTRACTION_CONCURRENCY;
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return DEFAULT_EXTRACTION_CONCURRENCY;
return Math.min(MAX_EXTRACTION_CONCURRENCY, Math.max(1, Math.floor(n)));
}
// Coerce a configured max_review_iterations into a usable integer: missing or
// non-numeric falls back to the default, and anything valid is floored to 1.
// Exported for tests.
export function normalizeReviewIterations(value: unknown): number {
// The same "absent means the default, not zero" trap as the other normalizers โ
// YAML parses a valueless `max_review_iterations:` as null and Number(null) is 0
// โ but here it has a second failure on top of a bad cap: null flows through
// makeAuthMiddleware to upsertUser, whose `= 3` parameter default only fires for
// `undefined`, so it reaches a NOT NULL column and the write throws.
//
// Today that is `500 server_error: This deployment could not record its own identity`,
// on the first request that reaches `/v1/me` or `/v1/sessions` โ the only two mounts
// `auth` is attached to. `/v1/health`, `/v1/stats`, `/v1/limits` and `/v1/quality` keep
// answering, which is what makes the typo hard to see: the deployment looks up.
// Before #459 the same typo answered
// `401 unauthorized: Token validation failed`, because `upsertUser` sat inside the
// catch that reported a GitHub refusal โ a config typo blamed on the caller's token.
// That is why the write has a catch of its own now (auth/middleware.ts). This guard is
// what keeps either report from being reached.
if (value === null || value === undefined) return DEFAULT_MAX_REVIEW_ITERATIONS;
if (typeof value === "string" && value.trim() === "") return DEFAULT_MAX_REVIEW_ITERATIONS;
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return DEFAULT_MAX_REVIEW_ITERATIONS;
// Floored, not clamped: unlike the two concurrency knobs there is no ceiling
// here, deliberately. Rounds are sequential, so a big number costs the operator
// who chose it latency and tokens on their own deployment โ it does not
// over-subscribe the machine and degrade every other run the way concurrency
// does. Silently capping a deliberate 20 would be the more surprising behavior.
return Math.max(1, Math.floor(n));
}
// Coerce a configured recheck_sample_size into a usable integer. The same "absent
// means the default, not zero" guard as the others, and the same reason it matters
// here: a valueless `recheck_sample_size:` parses as null, and Number(null) is 0 โ
// which is a legitimate value for this knob (measurement off), so obeying it would
// turn a YAML typo into a deployment that quietly stops collecting the only number it
// publishes about whether correction converges.
//
// Floored at 0 and not clamped above. 0 is meaningful, unlike the concurrency knobs;
// and there is no ceiling because the cost is bounded by the run โ the most this can
// buy is one Feedback Agent call per corrected page, so a deployment that sets 10,000
// gets a census and not a runaway. A negative is a value nobody can mean, and the
// nearest thing to it that this code can honour is off. So is a fraction below 1: this
// is a count of pages, and `0.5` floors to none โ which is worth knowing because it is
// the one input here that looks like it asks for a little measuring and turns it off.
//
// A value this cannot read at all resolves to the DEFAULT, which is the opposite
// direction from `recheckSampler`'s own guard on the same shape (there, an unusable size
// means no measurement). Deliberate, because the two answer different questions: this one
// asks what the operator meant by a line in their config, where a typo means "they did not
// set it", and the sampler asks what to do with a number it cannot use, where a garbled
// value must not be read as "measure everything". `loadConfig` runs first on every
// production path, so this is the rule that decides a deployment's behaviour.
// Exported for tests.
export function normalizeRecheckSampleSize(value: unknown): number {
if (value === null || value === undefined) return DEFAULT_RECHECK_SAMPLE_SIZE;
if (typeof value === "string" && value.trim() === "") return DEFAULT_RECHECK_SAMPLE_SIZE;
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return DEFAULT_RECHECK_SAMPLE_SIZE;
return Math.max(0, Math.floor(n));
}
let cached: { path: string; config: IrisConfig } | null = null;
export function loadConfig(path = process.env.IRIS_CONFIG ?? "config.yaml"): IrisConfig {
const resolved = resolve(path);
// Cache per resolved path: a caller passing a different config file must get
// that file, not whichever one happened to load first.
if (cached && cached.path === resolved) return cached.config;
const raw = readFileSync(resolved, "utf8");
const unset = new Set<string>();
const parsed = expandEnv(parse(raw), unset) as IrisConfig;
// Resolve filesystem paths to absolutes so the service is CWD-independent.
parsed.storage.data_dir = resolve(parsed.storage.data_dir);
parsed.storage.agents_dir = resolve(parsed.storage.agents_dir);
parsed.storage.database = resolve(parsed.storage.database);
// Normalize the request-volume budget here too, so the numbers `GET /v1/limits`
// publishes are the same object the limiters were built from. `trust_proxy` is
// deliberately NOT normalized here: index.ts needs the value as written to decide
// whether to warn about it (a `true` coerced to 1 in this function is a `true` nobody
// can warn about afterwards).
parsed.server = parsed.server ?? ({} as IrisConfig["server"]);
parsed.server.rate_limits = resolveRateLimits(parsed.server.rate_limits);
// GitHub host defaults (overridable for GitHub Enterprise / testing).
parsed.github.api_base_url = parsed.github.api_base_url || "https://api.github.com";
// Normalize the extraction concurrency knob once, here, so every consumer can
// trust it: absent/garbage -> default, out-of-range -> clamped. A deployment
// that sets 0 or a negative value means "don't parallelize" -> 1.
parsed.defaults = parsed.defaults ?? ({} as IrisConfig["defaults"]);
parsed.defaults.extraction_concurrency = normalizeConcurrency(
parsed.defaults.extraction_concurrency,
);
// Same treatment for the cross-session run cap, so the queue can be built
// straight from config with no fallback at the construction site.
parsed.defaults.max_concurrent_runs = normalizeMaxConcurrentRuns(
parsed.defaults.max_concurrent_runs,
);
// And for the review cap. This one is normalized here rather than at the point of
// use because there is no longer a point of use to guard: the value is read once,
// at first auth, to seed the account default that every later session inherits.
parsed.defaults.max_review_iterations = normalizeReviewIterations(
parsed.defaults.max_review_iterations,
);
// And for the measurement sample, so `recheckSampler` can be handed the number
// directly. It has no fallback of its own on purpose: a default parameter there
// would make a run that read no config look identical to one configured for 1.
parsed.defaults.recheck_sample_size = normalizeRecheckSampleSize(
parsed.defaults.recheck_sample_size,
);
// Same treatment for each provider's output ceiling, so an adapter can read
// block.max_tokens directly and never has to re-derive a default. Applied to
// every provider block present, not just the referenced ones: validateConfig
// deliberately skips unreferenced providers, but normalizing is free and keeps
// the invariant "if the block exists, its max_tokens is a valid integer".
for (const [key, block] of Object.entries(parsed.providers)) {
if (key === "default" || key === "per_agent") continue;
if (!block || typeof block !== "object") continue;
const b = block as ProviderBlock;
b.max_tokens = normalizeMaxTokens(b.max_tokens);
}
validateConfig(parsed, unset, path);
cached = { path: resolved, config: parsed };
return parsed;
}