@iris-eval/mcp-server 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +11 -3
  2. package/dist/audit-log-reader.d.ts +24 -0
  3. package/dist/audit-log-reader.js +87 -0
  4. package/dist/config/defaults.js +7 -1
  5. package/dist/custom-rule-store.d.ts +27 -0
  6. package/dist/custom-rule-store.js +188 -0
  7. package/dist/dashboard/assets/index-BEG5FYWH.css +1 -0
  8. package/dist/dashboard/assets/index-D9JHfSB2.js +12 -0
  9. package/dist/dashboard/index.html +2 -2
  10. package/dist/dashboard/routes/audit.d.ts +3 -0
  11. package/dist/dashboard/routes/audit.js +36 -0
  12. package/dist/dashboard/routes/eval-stats.js +9 -4
  13. package/dist/dashboard/routes/evaluations.js +3 -1
  14. package/dist/dashboard/routes/filters.js +5 -3
  15. package/dist/dashboard/routes/health.js +8 -1
  16. package/dist/dashboard/routes/index.d.ts +4 -0
  17. package/dist/dashboard/routes/index.js +4 -0
  18. package/dist/dashboard/routes/moments.d.ts +3 -0
  19. package/dist/dashboard/routes/moments.js +115 -0
  20. package/dist/dashboard/routes/preferences.d.ts +3 -0
  21. package/dist/dashboard/routes/preferences.js +52 -0
  22. package/dist/dashboard/routes/rules.d.ts +10 -0
  23. package/dist/dashboard/routes/rules.js +169 -0
  24. package/dist/dashboard/routes/summary.js +3 -1
  25. package/dist/dashboard/routes/traces.js +7 -4
  26. package/dist/dashboard/server.d.ts +9 -1
  27. package/dist/dashboard/server.js +52 -3
  28. package/dist/eval/citation-verify/extract.d.ts +11 -0
  29. package/dist/eval/citation-verify/extract.js +102 -0
  30. package/dist/eval/citation-verify/resolve.d.ts +26 -0
  31. package/dist/eval/citation-verify/resolve.js +237 -0
  32. package/dist/eval/citation-verify/verifier.d.ts +43 -0
  33. package/dist/eval/citation-verify/verifier.js +203 -0
  34. package/dist/eval/decision-moment.d.ts +12 -0
  35. package/dist/eval/decision-moment.js +181 -0
  36. package/dist/eval/llm-judge/client.d.ts +28 -0
  37. package/dist/eval/llm-judge/client.js +183 -0
  38. package/dist/eval/llm-judge/evaluator.d.ts +32 -0
  39. package/dist/eval/llm-judge/evaluator.js +138 -0
  40. package/dist/eval/llm-judge/pricing.d.ts +9 -0
  41. package/dist/eval/llm-judge/pricing.js +31 -0
  42. package/dist/eval/llm-judge/templates/index.d.ts +20 -0
  43. package/dist/eval/llm-judge/templates/index.js +170 -0
  44. package/dist/eval/rules/custom.js +13 -2
  45. package/dist/index.js +77 -14
  46. package/dist/middleware/index.d.ts +1 -0
  47. package/dist/middleware/index.js +1 -0
  48. package/dist/middleware/tenant.d.ts +17 -0
  49. package/dist/middleware/tenant.js +26 -0
  50. package/dist/otel/exporter.d.ts +24 -0
  51. package/dist/otel/exporter.js +116 -0
  52. package/dist/otel/lazy.d.ts +5 -0
  53. package/dist/otel/lazy.js +31 -0
  54. package/dist/otel/mapper.d.ts +24 -0
  55. package/dist/otel/mapper.js +208 -0
  56. package/dist/preferences.d.ts +129 -0
  57. package/dist/preferences.js +152 -0
  58. package/dist/resources/dashboard-summary.js +3 -1
  59. package/dist/resources/trace-detail.js +5 -3
  60. package/dist/server.d.ts +3 -1
  61. package/dist/server.js +9 -3
  62. package/dist/storage/migrations/004-tenant-id.d.ts +3 -0
  63. package/dist/storage/migrations/004-tenant-id.js +40 -0
  64. package/dist/storage/migrations/index.js +2 -1
  65. package/dist/storage/sqlite-adapter.d.ts +17 -15
  66. package/dist/storage/sqlite-adapter.js +130 -79
  67. package/dist/tools/delete-rule.d.ts +3 -0
  68. package/dist/tools/delete-rule.js +53 -0
  69. package/dist/tools/delete-trace.d.ts +3 -0
  70. package/dist/tools/delete-trace.js +54 -0
  71. package/dist/tools/deploy-rule.d.ts +3 -0
  72. package/dist/tools/deploy-rule.js +91 -0
  73. package/dist/tools/evaluate-output.js +23 -2
  74. package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
  75. package/dist/tools/evaluate-with-llm-judge.js +147 -0
  76. package/dist/tools/get-traces.js +23 -3
  77. package/dist/tools/index.d.ts +2 -1
  78. package/dist/tools/index.js +13 -1
  79. package/dist/tools/list-rules.d.ts +3 -0
  80. package/dist/tools/list-rules.js +66 -0
  81. package/dist/tools/log-trace.js +30 -2
  82. package/dist/tools/verify-citations.d.ts +3 -0
  83. package/dist/tools/verify-citations.js +157 -0
  84. package/dist/types/custom-rule.d.ts +70 -0
  85. package/dist/types/custom-rule.js +1 -0
  86. package/dist/types/decision-moment.d.ts +122 -0
  87. package/dist/types/decision-moment.js +17 -0
  88. package/dist/types/index.d.ts +2 -0
  89. package/dist/types/index.js +1 -1
  90. package/dist/types/query.d.ts +25 -15
  91. package/dist/types/tenant.d.ts +26 -0
  92. package/dist/types/tenant.js +58 -0
  93. package/dist/utils/open-browser.d.ts +1 -0
  94. package/dist/utils/open-browser.js +45 -0
  95. package/dist/utils/validate-port-config.d.ts +2 -0
  96. package/dist/utils/validate-port-config.js +9 -0
  97. package/package.json +4 -1
  98. package/server.json +2 -2
  99. package/dist/dashboard/assets/index-CnDg6bYi.js +0 -43
  100. package/dist/dashboard/assets/index-ZsBou2-c.css +0 -1
@@ -0,0 +1,203 @@
1
+ import { callLLMJudge, LLMJudgeError } from '../llm-judge/client.js';
2
+ import { estimateCostUsd, findPricing } from '../llm-judge/pricing.js';
3
+ import { extractCitations } from './extract.js';
4
+ import { resolveSource } from './resolve.js';
5
+ const SYSTEM = `You are a citation verification evaluator. Given a claim extracted from AI-generated output and the text of a cited source, decide whether the source supports the claim.
6
+
7
+ Score 0.00 means the source contradicts the claim or does not mention it.
8
+ Score 1.00 means the source clearly supports the claim.
9
+ Be strict: do not rate as supported unless the source actually contains the assertion. Do NOT penalize paraphrasing; DO penalize invented specifics not in the source.
10
+
11
+ Respond with a single JSON object — no markdown, no prose:
12
+ {
13
+ "supported": <boolean>,
14
+ "confidence": <number 0.00..1.00>,
15
+ "rationale": "<1-2 sentences — quote 5-15 words from the source if you found support>"
16
+ }`;
17
+ function buildUser(claim, sourceText) {
18
+ // Truncate huge sources so we stay within reasonable tokens.
19
+ const maxSourceChars = 12_000; // ~3k tokens
20
+ const trimmed = sourceText.length > maxSourceChars
21
+ ? sourceText.slice(0, maxSourceChars) + '\n\n[…source truncated…]'
22
+ : sourceText;
23
+ return `CLAIM:\n${claim}\n\nSOURCE TEXT:\n${trimmed}`;
24
+ }
25
+ function parseJudgeResult(raw) {
26
+ const trimmed = raw
27
+ .trim()
28
+ .replace(/^```json\s*/i, '')
29
+ .replace(/^```\s*/i, '')
30
+ .replace(/```$/i, '')
31
+ .trim();
32
+ const first = trimmed.indexOf('{');
33
+ const last = trimmed.lastIndexOf('}');
34
+ if (first < 0 || last <= first) {
35
+ throw new LLMJudgeError(`Citation judge did not emit JSON: ${raw.slice(0, 200)}`, 'malformed_response');
36
+ }
37
+ const obj = JSON.parse(trimmed.slice(first, last + 1));
38
+ const supported = obj.supported === true;
39
+ const confRaw = obj.confidence;
40
+ const confidence = typeof confRaw === 'number' ? confRaw : Number(confRaw);
41
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
42
+ throw new LLMJudgeError(`Citation judge confidence out of [0..1]: ${String(confRaw)}`, 'malformed_response');
43
+ }
44
+ const rationale = typeof obj.rationale === 'string' ? obj.rationale : '';
45
+ return { supported, confidence: Math.round(confidence * 100) / 100, rationale };
46
+ }
47
+ export async function verifyCitations(params) {
48
+ const citations = extractCitations(params.output);
49
+ const maxCitations = params.maxCitations ?? 20;
50
+ const selected = citations.slice(0, maxCitations);
51
+ const totalFound = citations.length;
52
+ if (!findPricing(params.model)) {
53
+ throw new Error(`Unknown model "${params.model}". Add pricing to src/eval/llm-judge/pricing.ts first.`);
54
+ }
55
+ const maxCostTotal = params.maxCostUsdTotal ?? 1.0;
56
+ const out = [];
57
+ let totalCost = 0;
58
+ let totalResolved = 0;
59
+ let totalSupported = 0;
60
+ for (const citation of selected) {
61
+ // Only URL/DOI can be resolved. Numbered citations without
62
+ // footnote definitions + author-year refs without a bibliography
63
+ // are flagged as unresolvable — the output claims them but we have
64
+ // nothing to compare against.
65
+ if (citation.kind !== 'url' && citation.kind !== 'doi') {
66
+ out.push({ citation, resolveStatus: 'skipped', resolveError: { kind: 'unresolvable_kind', message: `No source to fetch for ${citation.kind} citation` } });
67
+ continue;
68
+ }
69
+ let source;
70
+ try {
71
+ source = await resolveSource(citation.identifier, {
72
+ allowFetch: params.allowFetch,
73
+ timeoutMs: params.perSourceTimeoutMs,
74
+ maxBytes: params.perSourceMaxBytes,
75
+ domainAllowlist: params.domainAllowlist,
76
+ });
77
+ totalResolved++;
78
+ }
79
+ catch (err) {
80
+ const e = err;
81
+ out.push({
82
+ citation,
83
+ resolveStatus: 'error',
84
+ resolveError: { kind: e.kind ?? 'unknown', message: e.message },
85
+ });
86
+ continue;
87
+ }
88
+ // Before calling the judge: would this blow our total cost?
89
+ // Use the same pessimistic estimate as the main LLM judge evaluator.
90
+ const contextLen = citation.contextWindow.length + source.text.length;
91
+ const pessimistic = estimateCostUsd(params.model, Math.ceil(contextLen / 4), 512) ?? 0;
92
+ if (totalCost + pessimistic > maxCostTotal) {
93
+ out.push({
94
+ citation,
95
+ resolveStatus: 'ok',
96
+ source: {
97
+ url: source.url,
98
+ status: source.status,
99
+ contentType: source.contentType,
100
+ bytesFetched: source.bytesFetched,
101
+ truncated: source.truncated,
102
+ },
103
+ resolveError: {
104
+ kind: 'cost_cap_reached',
105
+ message: `Total cost cap $${maxCostTotal.toFixed(2)} would be exceeded by next judge call`,
106
+ },
107
+ });
108
+ break; // No point continuing — subsequent calls will also exceed.
109
+ }
110
+ let judgeResponse;
111
+ try {
112
+ judgeResponse = await callLLMJudge({
113
+ provider: params.provider,
114
+ model: params.model,
115
+ systemPrompt: SYSTEM,
116
+ userPrompt: buildUser(citation.contextWindow, source.text),
117
+ maxOutputTokens: 256,
118
+ temperature: 0,
119
+ apiKey: params.apiKey,
120
+ });
121
+ }
122
+ catch (err) {
123
+ const e = err;
124
+ out.push({
125
+ citation,
126
+ resolveStatus: 'ok',
127
+ source: {
128
+ url: source.url,
129
+ status: source.status,
130
+ contentType: source.contentType,
131
+ bytesFetched: source.bytesFetched,
132
+ truncated: source.truncated,
133
+ },
134
+ resolveError: {
135
+ kind: err instanceof LLMJudgeError ? err.kind : 'llm_judge_error',
136
+ message: e.message,
137
+ },
138
+ });
139
+ continue;
140
+ }
141
+ const cost = estimateCostUsd(params.model, judgeResponse.inputTokens, judgeResponse.outputTokens);
142
+ totalCost += cost ?? 0;
143
+ let parsed;
144
+ try {
145
+ parsed = parseJudgeResult(judgeResponse.content);
146
+ }
147
+ catch (err) {
148
+ const e = err;
149
+ out.push({
150
+ citation,
151
+ resolveStatus: 'ok',
152
+ source: {
153
+ url: source.url,
154
+ status: source.status,
155
+ contentType: source.contentType,
156
+ bytesFetched: source.bytesFetched,
157
+ truncated: source.truncated,
158
+ },
159
+ resolveError: {
160
+ kind: 'malformed_judge_response',
161
+ message: e.message,
162
+ },
163
+ });
164
+ continue;
165
+ }
166
+ if (parsed.supported)
167
+ totalSupported++;
168
+ out.push({
169
+ citation,
170
+ resolveStatus: 'ok',
171
+ source: {
172
+ url: source.url,
173
+ status: source.status,
174
+ contentType: source.contentType,
175
+ bytesFetched: source.bytesFetched,
176
+ truncated: source.truncated,
177
+ },
178
+ judge: {
179
+ supported: parsed.supported,
180
+ confidence: parsed.confidence,
181
+ rationale: parsed.rationale,
182
+ inputTokens: judgeResponse.inputTokens,
183
+ outputTokens: judgeResponse.outputTokens,
184
+ costUsd: cost,
185
+ latencyMs: judgeResponse.latencyMs,
186
+ },
187
+ });
188
+ }
189
+ const overallScore = totalResolved > 0 ? Math.round((totalSupported / totalResolved) * 100) / 100 : null;
190
+ // Fail if >= 50% of resolved sources don't support the claim. When
191
+ // no citations or none resolved, we don't fail — there's nothing to
192
+ // score, we just report that.
193
+ const passed = overallScore === null ? true : overallScore >= 0.5;
194
+ return {
195
+ overallScore,
196
+ passed,
197
+ citations: out,
198
+ totalCostUsd: Math.round(totalCost * 1_000_000) / 1_000_000,
199
+ totalCitationsFound: totalFound,
200
+ totalResolved,
201
+ totalSupported,
202
+ };
203
+ }
@@ -0,0 +1,12 @@
1
+ import type { Trace } from '../types/trace.js';
2
+ import type { EvalResult } from '../types/eval.js';
3
+ import type { DecisionMoment, DecisionMomentDetail } from '../types/decision-moment.js';
4
+ export declare function deriveMoment(trace: Trace, evals: EvalResult[]): DecisionMoment;
5
+ export declare function deriveMomentDetail(trace: Trace, evals: EvalResult[], spans?: Array<{
6
+ span_id: string;
7
+ parent_span_id?: string;
8
+ name: string;
9
+ kind: string;
10
+ start_time: string;
11
+ end_time?: string;
12
+ }>): DecisionMomentDetail;
@@ -0,0 +1,181 @@
1
+ /*
2
+ * decision-moment — derive Decision Moments from trace + eval data.
3
+ *
4
+ * A Decision Moment aggregates one trace and all eval results recorded for
5
+ * it. The significance classifier runs over the aggregated view to decide
6
+ * whether this trace is moment-worthy (safety violation, cost spike, etc.)
7
+ * or normal operational data.
8
+ *
9
+ * The classifier is intentionally simple in v0.4: rules-based, deterministic,
10
+ * no learned baselines. The "first-failure" and "novel-pattern" classes need
11
+ * agent-history context that we add in v0.4.1 — for now they fall through to
12
+ * the simpler categories.
13
+ */
14
+ /* Cost-spike threshold in USD per single trace. Crossing this triggers
15
+ * cost-spike classification regardless of agent baseline. The bound was
16
+ * picked to flag any single trace that costs more than a typical
17
+ * developer-tier monthly budget would absorb at scale (1000 traces/day). */
18
+ const COST_SPIKE_USD_THRESHOLD = 0.10;
19
+ /* Rule names that, if failed, escalate the moment to safety-violation
20
+ * regardless of the rest of the verdict. Keeps in sync with v0.3.1's
21
+ * safety category. */
22
+ const SAFETY_RULE_NAMES = new Set([
23
+ 'no_pii',
24
+ 'no_blocklist_words',
25
+ 'no_injection_patterns',
26
+ 'no_stub_output',
27
+ ]);
28
+ export function deriveMoment(trace, evals) {
29
+ const ruleSnapshot = computeRuleSnapshot(evals);
30
+ const verdict = computeVerdict(evals, ruleSnapshot);
31
+ const overallScore = computeOverallScore(evals);
32
+ const significance = classifySignificance({
33
+ trace,
34
+ evals,
35
+ ruleSnapshot,
36
+ verdict,
37
+ });
38
+ return {
39
+ id: trace.trace_id,
40
+ traceId: trace.trace_id,
41
+ agentName: trace.agent_name,
42
+ timestamp: trace.timestamp,
43
+ input: trace.input,
44
+ output: trace.output,
45
+ costUsd: trace.cost_usd,
46
+ latencyMs: trace.latency_ms,
47
+ verdict,
48
+ overallScore,
49
+ evalCount: evals.length,
50
+ ruleSnapshot,
51
+ significance,
52
+ };
53
+ }
54
+ export function deriveMomentDetail(trace, evals, spans) {
55
+ const moment = deriveMoment(trace, evals);
56
+ return {
57
+ ...moment,
58
+ evals: evals.map((e) => ({
59
+ id: e.id,
60
+ evalType: e.eval_type,
61
+ score: e.score,
62
+ passed: e.passed,
63
+ ruleResults: e.rule_results.map((r) => ({
64
+ ruleName: r.ruleName,
65
+ passed: r.passed,
66
+ score: r.score,
67
+ message: r.message,
68
+ skipped: r.skipped,
69
+ skipReason: r.skipReason,
70
+ })),
71
+ suggestions: e.suggestions ?? [],
72
+ createdAt: e.created_at,
73
+ })),
74
+ toolCalls: trace.tool_calls,
75
+ spans,
76
+ };
77
+ }
78
+ function computeRuleSnapshot(evals) {
79
+ const failed = [];
80
+ const skipped = [];
81
+ let passedCount = 0;
82
+ let totalCount = 0;
83
+ for (const e of evals) {
84
+ for (const r of e.rule_results) {
85
+ totalCount++;
86
+ if (r.skipped) {
87
+ skipped.push(r.ruleName);
88
+ }
89
+ else if (r.passed) {
90
+ passedCount++;
91
+ }
92
+ else {
93
+ failed.push(r.ruleName);
94
+ }
95
+ }
96
+ }
97
+ return { failed, skipped, passedCount, totalCount };
98
+ }
99
+ function computeVerdict(evals, snapshot) {
100
+ if (evals.length === 0)
101
+ return 'unevaluated';
102
+ if (snapshot.totalCount - snapshot.skipped.length === 0)
103
+ return 'unevaluated';
104
+ if (snapshot.failed.length === 0)
105
+ return 'pass';
106
+ if (snapshot.passedCount === 0)
107
+ return 'fail';
108
+ return 'partial';
109
+ }
110
+ function computeOverallScore(evals) {
111
+ if (evals.length === 0)
112
+ return 0;
113
+ const sum = evals.reduce((a, e) => a + e.score, 0);
114
+ return sum / evals.length;
115
+ }
116
+ function classifySignificance({ trace, evals, ruleSnapshot, verdict, }) {
117
+ // 1. Safety violation — any safety rule failed → top priority.
118
+ const safetyFailed = ruleSnapshot.failed.filter((name) => SAFETY_RULE_NAMES.has(name));
119
+ if (safetyFailed.length > 0) {
120
+ return {
121
+ kind: 'safety-violation',
122
+ score: 1.0,
123
+ label: `Safety: ${safetyFailed.join(', ')}`,
124
+ reason: `${safetyFailed.length} safety rule(s) failed: ${safetyFailed.join(', ')}. Output may contain PII, prompt injection compliance, blocklisted content, or stub markers — review before this pattern becomes load-bearing.`,
125
+ };
126
+ }
127
+ // 2. Cost spike — trace cost over absolute threshold.
128
+ if (trace.cost_usd !== undefined && trace.cost_usd >= COST_SPIKE_USD_THRESHOLD) {
129
+ return {
130
+ kind: 'cost-spike',
131
+ score: 0.9,
132
+ label: `Cost: $${trace.cost_usd.toFixed(4)}`,
133
+ reason: `Trace cost ($${trace.cost_usd.toFixed(4)}) crossed the $${COST_SPIKE_USD_THRESHOLD} per-trace threshold. Investigate prompt size, token efficiency, or model-tier choice.`,
134
+ };
135
+ }
136
+ // 3. Rule collision — failures spanning multiple eval_types simultaneously.
137
+ if (ruleSnapshot.failed.length > 0) {
138
+ const failedEvalTypes = new Set(evals.filter((e) => !e.passed).map((e) => e.eval_type));
139
+ if (failedEvalTypes.size >= 2) {
140
+ return {
141
+ kind: 'rule-collision',
142
+ score: 0.7,
143
+ label: `Multi-category fail (${failedEvalTypes.size})`,
144
+ reason: `Failures across ${failedEvalTypes.size} eval categories: ${[...failedEvalTypes].join(', ')}. Failed rules: ${ruleSnapshot.failed.join(', ')}.`,
145
+ };
146
+ }
147
+ }
148
+ // 4. Generic fail.
149
+ if (verdict === 'fail') {
150
+ return {
151
+ kind: 'normal-fail',
152
+ score: 0.5,
153
+ label: `Fail: ${ruleSnapshot.failed.join(', ')}`,
154
+ reason: `Eval verdict fail. Failed rules: ${ruleSnapshot.failed.join(', ')}.`,
155
+ };
156
+ }
157
+ if (verdict === 'partial') {
158
+ return {
159
+ kind: 'normal-fail',
160
+ score: 0.4,
161
+ label: `Partial: ${ruleSnapshot.failed.length} failed`,
162
+ reason: `Partial fail — ${ruleSnapshot.failed.length} of ${ruleSnapshot.totalCount - ruleSnapshot.skipped.length} fired rules failed: ${ruleSnapshot.failed.join(', ')}.`,
163
+ };
164
+ }
165
+ // 5. Unevaluated trace (no eval recorded).
166
+ if (verdict === 'unevaluated') {
167
+ return {
168
+ kind: 'normal-pass',
169
+ score: 0.1,
170
+ label: 'No eval recorded',
171
+ reason: 'No eval was recorded for this trace. The agent ran but no rules fired.',
172
+ };
173
+ }
174
+ // 6. Happy path — clean pass.
175
+ return {
176
+ kind: 'normal-pass',
177
+ score: 0.05,
178
+ label: 'Pass',
179
+ reason: `All ${ruleSnapshot.passedCount} fired rules passed.`,
180
+ };
181
+ }
@@ -0,0 +1,28 @@
1
+ export type LLMProvider = 'anthropic' | 'openai';
2
+ export interface LLMJudgeRequest {
3
+ provider: LLMProvider;
4
+ model: string;
5
+ systemPrompt: string;
6
+ userPrompt: string;
7
+ maxOutputTokens: number;
8
+ temperature: number;
9
+ apiKey: string;
10
+ timeoutMs?: number;
11
+ maxInputTokensEstimate?: number;
12
+ }
13
+ export interface LLMJudgeResponse {
14
+ content: string;
15
+ inputTokens: number;
16
+ outputTokens: number;
17
+ stopReason: string;
18
+ latencyMs: number;
19
+ rawProviderResponseId?: string;
20
+ }
21
+ export declare class LLMJudgeError extends Error {
22
+ readonly kind: 'auth' | 'rate_limit' | 'bad_request' | 'server_error' | 'timeout' | 'malformed_response' | 'unknown';
23
+ readonly statusCode?: number | undefined;
24
+ readonly retryAfterSeconds?: number | undefined;
25
+ constructor(message: string, kind: 'auth' | 'rate_limit' | 'bad_request' | 'server_error' | 'timeout' | 'malformed_response' | 'unknown', statusCode?: number | undefined, retryAfterSeconds?: number | undefined);
26
+ }
27
+ export declare function estimateInputTokens(systemPrompt: string, userPrompt: string): number;
28
+ export declare function callLLMJudge(req: LLMJudgeRequest): Promise<LLMJudgeResponse>;
@@ -0,0 +1,183 @@
1
+ // LLM client abstraction for the judge eval path. Two providers are
2
+ // implemented (Anthropic + OpenAI); both are thin wrappers around the
3
+ // respective completion endpoints. Iris never imports the vendor SDKs at
4
+ // runtime — the wire format is simple enough that a fetch() call + a
5
+ // small amount of response parsing keeps the dependency surface minimal
6
+ // and the supply-chain footprint smaller.
7
+ //
8
+ // Guarantees:
9
+ // - AbortSignal-respecting timeouts
10
+ // - Retry on 429 (single retry w/ RateLimit-* header or Retry-After)
11
+ // - Structured error types (LLMJudgeError) — callers distinguish a
12
+ // transient rate-limit from a permanent auth failure
13
+ // - Usage fields (input_tokens / output_tokens) surfaced so the
14
+ // evaluator can compute cost without a second API call
15
+ export class LLMJudgeError extends Error {
16
+ kind;
17
+ statusCode;
18
+ retryAfterSeconds;
19
+ constructor(message, kind, statusCode, retryAfterSeconds) {
20
+ super(message);
21
+ this.kind = kind;
22
+ this.statusCode = statusCode;
23
+ this.retryAfterSeconds = retryAfterSeconds;
24
+ this.name = 'LLMJudgeError';
25
+ }
26
+ }
27
+ // Very rough token estimate — good enough to reject obviously-too-big
28
+ // prompts before round-tripping. Real tokenization differs per model;
29
+ // both Anthropic and OpenAI hover near 4 chars/token for English text.
30
+ export function estimateInputTokens(systemPrompt, userPrompt) {
31
+ const chars = systemPrompt.length + userPrompt.length;
32
+ return Math.ceil(chars / 4);
33
+ }
34
+ async function fetchWithTimeout(url, init, timeoutMs) {
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
37
+ try {
38
+ return await fetch(url, { ...init, signal: controller.signal });
39
+ }
40
+ catch (err) {
41
+ if (err instanceof Error && err.name === 'AbortError') {
42
+ throw new LLMJudgeError(`Request timed out after ${timeoutMs}ms`, 'timeout');
43
+ }
44
+ throw err;
45
+ }
46
+ finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+ function parseRetryAfter(headers) {
51
+ const raw = headers.get('retry-after') ?? headers.get('anthropic-ratelimit-requests-reset');
52
+ if (!raw)
53
+ return undefined;
54
+ const asSeconds = Number(raw);
55
+ if (Number.isFinite(asSeconds) && asSeconds > 0)
56
+ return asSeconds;
57
+ const asDate = Date.parse(raw);
58
+ if (Number.isFinite(asDate)) {
59
+ return Math.max(1, Math.ceil((asDate - Date.now()) / 1000));
60
+ }
61
+ return undefined;
62
+ }
63
+ async function callAnthropic(req) {
64
+ const timeoutMs = req.timeoutMs ?? 60_000;
65
+ const started = Date.now();
66
+ const res = await fetchWithTimeout('https://api.anthropic.com/v1/messages', {
67
+ method: 'POST',
68
+ headers: {
69
+ 'content-type': 'application/json',
70
+ 'x-api-key': req.apiKey,
71
+ 'anthropic-version': '2023-06-01',
72
+ },
73
+ body: JSON.stringify({
74
+ model: req.model,
75
+ max_tokens: req.maxOutputTokens,
76
+ temperature: req.temperature,
77
+ system: req.systemPrompt,
78
+ messages: [{ role: 'user', content: req.userPrompt }],
79
+ }),
80
+ }, timeoutMs);
81
+ const latencyMs = Date.now() - started;
82
+ if (!res.ok) {
83
+ const body = await res.text().catch(() => '');
84
+ const retryAfterSeconds = parseRetryAfter(res.headers);
85
+ if (res.status === 401 || res.status === 403) {
86
+ throw new LLMJudgeError(`Anthropic auth failed (${res.status}): ${body}`, 'auth', res.status);
87
+ }
88
+ if (res.status === 429) {
89
+ throw new LLMJudgeError(`Anthropic rate-limited (${res.status})`, 'rate_limit', res.status, retryAfterSeconds);
90
+ }
91
+ if (res.status >= 500) {
92
+ throw new LLMJudgeError(`Anthropic server error (${res.status}): ${body}`, 'server_error', res.status);
93
+ }
94
+ throw new LLMJudgeError(`Anthropic bad request (${res.status}): ${body}`, 'bad_request', res.status);
95
+ }
96
+ const json = (await res.json().catch(() => {
97
+ throw new LLMJudgeError('Anthropic response was not valid JSON', 'malformed_response');
98
+ }));
99
+ const firstText = (json.content ?? []).find((c) => c.type === 'text')?.text;
100
+ if (!firstText) {
101
+ throw new LLMJudgeError('Anthropic response had no text content block', 'malformed_response');
102
+ }
103
+ return {
104
+ content: firstText,
105
+ inputTokens: json.usage?.input_tokens ?? 0,
106
+ outputTokens: json.usage?.output_tokens ?? 0,
107
+ stopReason: json.stop_reason ?? 'unknown',
108
+ latencyMs,
109
+ rawProviderResponseId: json.id,
110
+ };
111
+ }
112
+ async function callOpenAI(req) {
113
+ const timeoutMs = req.timeoutMs ?? 60_000;
114
+ const started = Date.now();
115
+ const res = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
116
+ method: 'POST',
117
+ headers: {
118
+ 'content-type': 'application/json',
119
+ authorization: `Bearer ${req.apiKey}`,
120
+ },
121
+ body: JSON.stringify({
122
+ model: req.model,
123
+ max_tokens: req.maxOutputTokens,
124
+ temperature: req.temperature,
125
+ messages: [
126
+ { role: 'system', content: req.systemPrompt },
127
+ { role: 'user', content: req.userPrompt },
128
+ ],
129
+ }),
130
+ }, timeoutMs);
131
+ const latencyMs = Date.now() - started;
132
+ if (!res.ok) {
133
+ const body = await res.text().catch(() => '');
134
+ const retryAfterSeconds = parseRetryAfter(res.headers);
135
+ if (res.status === 401 || res.status === 403) {
136
+ throw new LLMJudgeError(`OpenAI auth failed (${res.status}): ${body}`, 'auth', res.status);
137
+ }
138
+ if (res.status === 429) {
139
+ throw new LLMJudgeError(`OpenAI rate-limited (${res.status})`, 'rate_limit', res.status, retryAfterSeconds);
140
+ }
141
+ if (res.status >= 500) {
142
+ throw new LLMJudgeError(`OpenAI server error (${res.status}): ${body}`, 'server_error', res.status);
143
+ }
144
+ throw new LLMJudgeError(`OpenAI bad request (${res.status}): ${body}`, 'bad_request', res.status);
145
+ }
146
+ const json = (await res.json().catch(() => {
147
+ throw new LLMJudgeError('OpenAI response was not valid JSON', 'malformed_response');
148
+ }));
149
+ const firstText = json.choices?.[0]?.message?.content;
150
+ if (!firstText) {
151
+ throw new LLMJudgeError('OpenAI response had no message content', 'malformed_response');
152
+ }
153
+ return {
154
+ content: firstText,
155
+ inputTokens: json.usage?.prompt_tokens ?? 0,
156
+ outputTokens: json.usage?.completion_tokens ?? 0,
157
+ stopReason: json.choices?.[0]?.finish_reason ?? 'unknown',
158
+ latencyMs,
159
+ rawProviderResponseId: json.id,
160
+ };
161
+ }
162
+ // Single-retry-on-429 wrapper. Reads Retry-After/RateLimit-Reset header
163
+ // when present; otherwise sleeps 2s. Deliberately one retry — repeated
164
+ // retries burn cost on what is usually a provisioning problem, not a
165
+ // transient spike. Callers that want more aggressive retry can compose.
166
+ export async function callLLMJudge(req) {
167
+ const estimatedInput = estimateInputTokens(req.systemPrompt, req.userPrompt);
168
+ if (req.maxInputTokensEstimate && estimatedInput > req.maxInputTokensEstimate) {
169
+ throw new LLMJudgeError(`Estimated input tokens (${estimatedInput}) exceed cap (${req.maxInputTokensEstimate}) — refusing to call`, 'bad_request');
170
+ }
171
+ const call = req.provider === 'anthropic' ? callAnthropic : callOpenAI;
172
+ try {
173
+ return await call(req);
174
+ }
175
+ catch (err) {
176
+ if (err instanceof LLMJudgeError && err.kind === 'rate_limit') {
177
+ const waitSeconds = err.retryAfterSeconds ?? 2;
178
+ await new Promise((r) => setTimeout(r, waitSeconds * 1000));
179
+ return await call(req);
180
+ }
181
+ throw err;
182
+ }
183
+ }
@@ -0,0 +1,32 @@
1
+ import { type LLMProvider } from './client.js';
2
+ import { type TemplateName } from './templates/index.js';
3
+ export interface LLMJudgeEvaluateParams {
4
+ output: string;
5
+ template: TemplateName;
6
+ provider: LLMProvider;
7
+ model: string;
8
+ apiKey: string;
9
+ input?: string;
10
+ expected?: string;
11
+ sourceMaterial?: string;
12
+ maxCostUsdPerEval?: number;
13
+ maxOutputTokens?: number;
14
+ timeoutMs?: number;
15
+ temperature?: number;
16
+ maxInputTokensEstimate?: number;
17
+ }
18
+ export interface LLMJudgeEvaluationResult {
19
+ passed: boolean;
20
+ score: number;
21
+ rationale: string;
22
+ dimensions: Record<string, number>;
23
+ model: string;
24
+ provider: LLMProvider;
25
+ template: TemplateName;
26
+ inputTokens: number;
27
+ outputTokens: number;
28
+ costUsd: number | null;
29
+ latencyMs: number;
30
+ rawResponseId?: string;
31
+ }
32
+ export declare function evaluateWithLLMJudge(params: LLMJudgeEvaluateParams): Promise<LLMJudgeEvaluationResult>;