gavio 0.2.0 → 0.3.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 (56) hide show
  1. package/dist/cjs/gateway.js +46 -0
  2. package/dist/cjs/index.js +4 -2
  3. package/dist/cjs/interceptors/audit/interceptor.js +4 -0
  4. package/dist/cjs/interceptors/audit/record.js +17 -3
  5. package/dist/cjs/interceptors/metrics/index.js +9 -0
  6. package/dist/cjs/interceptors/metrics/interceptor.js +37 -0
  7. package/dist/cjs/interceptors/metrics/registry.js +0 -0
  8. package/dist/cjs/interceptors/quality/index.js +7 -0
  9. package/dist/cjs/interceptors/quality/risk.js +49 -0
  10. package/dist/cjs/interceptors/reliability/index.js +3 -1
  11. package/dist/cjs/interceptors/reliability/stream-buffer.js +28 -0
  12. package/dist/cjs/providers/base.js +9 -0
  13. package/dist/cjs/request.js +3 -0
  14. package/dist/cjs/types.js +53 -1
  15. package/dist/esm/gateway.d.ts +13 -1
  16. package/dist/esm/gateway.js +46 -0
  17. package/dist/esm/index.d.ts +3 -3
  18. package/dist/esm/index.js +2 -2
  19. package/dist/esm/interceptors/audit/interceptor.js +4 -0
  20. package/dist/esm/interceptors/audit/record.d.ts +4 -2
  21. package/dist/esm/interceptors/audit/record.js +18 -4
  22. package/dist/esm/interceptors/metrics/index.d.ts +5 -0
  23. package/dist/esm/interceptors/metrics/index.js +3 -0
  24. package/dist/esm/interceptors/metrics/interceptor.d.ts +22 -0
  25. package/dist/esm/interceptors/metrics/interceptor.js +33 -0
  26. package/dist/esm/interceptors/metrics/registry.d.ts +31 -0
  27. package/dist/esm/interceptors/metrics/registry.js +0 -0
  28. package/dist/esm/interceptors/quality/index.d.ts +3 -0
  29. package/dist/esm/interceptors/quality/index.js +2 -0
  30. package/dist/esm/interceptors/quality/risk.d.ts +32 -0
  31. package/dist/esm/interceptors/quality/risk.js +44 -0
  32. package/dist/esm/interceptors/reliability/index.d.ts +1 -0
  33. package/dist/esm/interceptors/reliability/index.js +1 -0
  34. package/dist/esm/interceptors/reliability/stream-buffer.d.ts +18 -0
  35. package/dist/esm/interceptors/reliability/stream-buffer.js +24 -0
  36. package/dist/esm/providers/base.d.ts +7 -0
  37. package/dist/esm/providers/base.js +9 -1
  38. package/dist/esm/request.d.ts +4 -1
  39. package/dist/esm/request.js +4 -1
  40. package/dist/esm/types.d.ts +54 -0
  41. package/dist/esm/types.js +50 -0
  42. package/package.json +11 -1
  43. package/src/gateway.ts +52 -1
  44. package/src/index.ts +4 -2
  45. package/src/interceptors/audit/interceptor.ts +4 -0
  46. package/src/interceptors/audit/record.ts +18 -4
  47. package/src/interceptors/metrics/index.ts +6 -0
  48. package/src/interceptors/metrics/interceptor.ts +46 -0
  49. package/src/interceptors/metrics/registry.ts +0 -0
  50. package/src/interceptors/quality/index.ts +4 -0
  51. package/src/interceptors/quality/risk.ts +64 -0
  52. package/src/interceptors/reliability/index.ts +1 -0
  53. package/src/interceptors/reliability/stream-buffer.ts +27 -0
  54. package/src/providers/base.ts +21 -1
  55. package/src/request.ts +6 -2
  56. package/src/types.ts +77 -0
@@ -3,12 +3,14 @@
3
3
  import type { InterceptorContext } from '../../context.js'
4
4
  import type { GavioRequest } from '../../request.js'
5
5
  import type { GavioResponse } from '../../response.js'
6
+ import type { PromptLineage } from '../../types.js'
6
7
  import type { Interceptor } from '../base.js'
7
8
  import { AuditRecord } from './record.js'
8
9
  import type { AuditSink } from './sink.js'
9
10
  import { stdoutSink } from './sinks/stdout.js'
10
11
 
11
12
  const PROMPT_HASH_KEY = 'audit_prompt_hash'
13
+ const LINEAGE_KEY = 'audit_lineage'
12
14
 
13
15
  export const AUDIT_NAME = 'audit'
14
16
 
@@ -41,6 +43,7 @@ class AuditInterceptor implements Interceptor {
41
43
 
42
44
  async before(request: GavioRequest, ctx: InterceptorContext): Promise<GavioRequest> {
43
45
  ctx.state[PROMPT_HASH_KEY] = AuditRecord.hashText(request.promptText())
46
+ if (request.lineage != null) ctx.state[LINEAGE_KEY] = request.lineage
44
47
  return request
45
48
  }
46
49
 
@@ -69,6 +72,7 @@ class AuditInterceptor implements Interceptor {
69
72
  cacheType: response.cacheType,
70
73
  guardrailOutcome: ctx.guardrailOutcome,
71
74
  riskScore: ctx.riskScore,
75
+ lineage: (ctx.state[LINEAGE_KEY] as PromptLineage | undefined) ?? null,
72
76
  })
73
77
  if (this.hashChain) {
74
78
  record.previousHash = this.lastHash
@@ -1,7 +1,7 @@
1
1
  /** AuditRecord — the immutable, per-request audit entry. */
2
2
 
3
3
  import { createHash } from 'node:crypto'
4
- import { TokenUsage } from '../../types.js'
4
+ import { PromptLineage, TokenUsage } from '../../types.js'
5
5
 
6
6
  export const SCHEMA_VERSION = '1.0'
7
7
 
@@ -9,6 +9,17 @@ function sha256(text: string): string {
9
9
  return createHash('sha256').update(text, 'utf-8').digest('hex')
10
10
  }
11
11
 
12
+ /** Deterministic JSON with keys sorted at every nesting level. */
13
+ function stableStringify(value: unknown): string {
14
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
15
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
16
+ const obj = value as Record<string, unknown>
17
+ const parts = Object.keys(obj)
18
+ .sort()
19
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
20
+ return `{${parts.join(',')}}`
21
+ }
22
+
12
23
  export interface AuditRecordInit {
13
24
  traceId: string
14
25
  provider: string
@@ -30,6 +41,7 @@ export interface AuditRecordInit {
30
41
  cacheType?: string | null
31
42
  guardrailOutcome?: string | null
32
43
  riskScore?: number | null
44
+ lineage?: PromptLineage | null
33
45
  previousHash?: string
34
46
  schemaVersion?: string
35
47
  }
@@ -62,6 +74,7 @@ export class AuditRecord {
62
74
  cacheType: string | null
63
75
  guardrailOutcome: string | null
64
76
  riskScore: number | null
77
+ lineage: PromptLineage | null
65
78
  previousHash: string
66
79
  schemaVersion: string
67
80
 
@@ -86,6 +99,7 @@ export class AuditRecord {
86
99
  this.cacheType = init.cacheType ?? null
87
100
  this.guardrailOutcome = init.guardrailOutcome ?? null
88
101
  this.riskScore = init.riskScore ?? null
102
+ this.lineage = init.lineage ?? null
89
103
  this.previousHash = init.previousHash ?? ''
90
104
  this.schemaVersion = init.schemaVersion ?? SCHEMA_VERSION
91
105
  }
@@ -120,15 +134,15 @@ export class AuditRecord {
120
134
  cacheType: this.cacheType,
121
135
  guardrailOutcome: this.guardrailOutcome,
122
136
  riskScore: this.riskScore,
137
+ lineage: this.lineage ? this.lineage.toJSON() : null,
123
138
  previousHash: this.previousHash,
124
139
  schemaVersion: this.schemaVersion,
125
140
  }
126
141
  }
127
142
 
128
- /** Stable JSON with sorted keys — used for the v0.2.0 hash chain. */
143
+ /** Stable JSON with recursively sorted keys — used for the v0.2.0 hash chain. */
129
144
  toCanonicalJson(): string {
130
- const data = this.toJSON()
131
- return JSON.stringify(data, Object.keys(data).sort())
145
+ return stableStringify(this.toJSON())
132
146
  }
133
147
 
134
148
  /** Hash of this record's content — used to build the v0.2.0 chain. */
@@ -0,0 +1,6 @@
1
+ /** Prometheus metrics (F-OBS-08). */
2
+
3
+ export { PrometheusMetrics } from './registry.js'
4
+ export type { RecordSample } from './registry.js'
5
+ export { metricsInterceptor, METRICS_NAME } from './interceptor.js'
6
+ export type { MetricsInterceptor } from './interceptor.js'
@@ -0,0 +1,46 @@
1
+ /** metricsInterceptor (F-OBS-08) — records Prometheus metrics per request. */
2
+
3
+ import type { InterceptorContext } from '../../context.js'
4
+ import type { GavioResponse } from '../../response.js'
5
+ import type { Interceptor } from '../base.js'
6
+ import { PrometheusMetrics } from './registry.js'
7
+
8
+ export const METRICS_NAME = 'metrics'
9
+
10
+ /** An interceptor that also exposes the registry it records into. */
11
+ export interface MetricsInterceptor extends Interceptor {
12
+ readonly metrics: PrometheusMetrics
13
+ }
14
+
15
+ /**
16
+ * Build a metrics interceptor. Pass a shared {@link PrometheusMetrics} registry
17
+ * (or let it create one) and scrape it via `.metrics.render()`:
18
+ *
19
+ * ```ts
20
+ * const m = metricsInterceptor()
21
+ * const gw = new Gateway({ devMode: true }).use(m)
22
+ * // ...
23
+ * console.log(m.metrics.render())
24
+ * ```
25
+ *
26
+ * Observation-only, so it always runs (including in dry-run).
27
+ */
28
+ export function metricsInterceptor(
29
+ metrics: PrometheusMetrics = new PrometheusMetrics(),
30
+ ): MetricsInterceptor {
31
+ return {
32
+ name: METRICS_NAME,
33
+ dryRunSafe: true,
34
+ metrics,
35
+ async after(response: GavioResponse, _ctx: InterceptorContext): Promise<GavioResponse> {
36
+ metrics.record(response.provider, response.model, {
37
+ promptTokens: response.usage.promptTokens,
38
+ completionTokens: response.usage.completionTokens,
39
+ costUsd: response.costUsd,
40
+ latencyMs: response.latencyMs,
41
+ cacheHit: response.cacheHit,
42
+ })
43
+ return response
44
+ },
45
+ }
46
+ }
@@ -0,0 +1,4 @@
1
+ /** Quality & compliance interceptors (F-QUA-06 risk scoring; F-QUA-03/04 to come). */
2
+
3
+ export { RiskScorer, riskScorer } from './risk.js'
4
+ export type { RiskWeights } from './risk.js'
@@ -0,0 +1,64 @@
1
+ /**
2
+ * RiskScorer (F-QUA-06) — a composite risk score from per-request signals.
3
+ *
4
+ * Folds the signals other interceptors leave on the {@link InterceptorContext}
5
+ * — PII entities found, guardrail outcome, and the prompt-injection risk — into
6
+ * a single score in `[0, 1]` written to `ctx.riskScore` (and thus the audit
7
+ * record). Register it *inside* the audit interceptor so audit sees the composite.
8
+ */
9
+
10
+ import type { InterceptorContext } from '../../context.js'
11
+ import type { GavioResponse } from '../../response.js'
12
+ import type { Interceptor } from '../base.js'
13
+
14
+ export interface RiskWeights {
15
+ pii?: number
16
+ guardrail?: number
17
+ injection?: number
18
+ /** PII entity count at which the PII signal saturates to 1.0 (<= 0 → any PII = 1.0). */
19
+ piiSaturation?: number
20
+ }
21
+
22
+ // Guardrail outcome → its contribution before weighting.
23
+ const GUARDRAIL_SIGNAL: Record<string, number> = { FAIL: 1.0, HITL: 0.6 }
24
+
25
+ export class RiskScorer implements Interceptor {
26
+ readonly name = 'risk_scorer'
27
+ readonly dryRunSafe = true
28
+
29
+ private readonly pii: number
30
+ private readonly guardrail: number
31
+ private readonly injection: number
32
+ private readonly piiSaturation: number
33
+
34
+ constructor(weights: RiskWeights = {}) {
35
+ this.pii = weights.pii ?? 0.3
36
+ this.guardrail = weights.guardrail ?? 0.4
37
+ this.injection = weights.injection ?? 0.3
38
+ this.piiSaturation = weights.piiSaturation ?? 4
39
+ }
40
+
41
+ /** Compute the composite risk score from the three raw signals. */
42
+ score(piiCount: number, guardrailOutcome: string | null, injectionScore: number | null): number {
43
+ let piiSignal = 0
44
+ if (piiCount > 0) {
45
+ piiSignal = this.piiSaturation <= 0 ? 1 : Math.min(1, piiCount / this.piiSaturation)
46
+ }
47
+ const guardrailSignal = GUARDRAIL_SIGNAL[guardrailOutcome ?? ''] ?? 0
48
+ const injectionSignal = injectionScore ?? 0
49
+ const composite =
50
+ this.pii * piiSignal + this.guardrail * guardrailSignal + this.injection * injectionSignal
51
+ return Math.max(0, Math.min(1, composite))
52
+ }
53
+
54
+ async after(response: GavioResponse, ctx: InterceptorContext): Promise<GavioResponse> {
55
+ const piiCount = Object.values(ctx.piiEntityCounts).reduce((a, b) => a + b, 0)
56
+ ctx.riskScore = this.score(piiCount, ctx.guardrailOutcome, ctx.riskScore)
57
+ return response
58
+ }
59
+ }
60
+
61
+ /** Build a risk scorer. */
62
+ export function riskScorer(weights: RiskWeights = {}): RiskScorer {
63
+ return new RiskScorer(weights)
64
+ }
@@ -10,3 +10,4 @@ export { circuitBreaker, CircuitState } from './circuit-breaker.js'
10
10
  export type { CircuitBreakerOptions } from './circuit-breaker.js'
11
11
  export { loadBalancer } from './load-balancer.js'
12
12
  export type { LoadBalancerOptions } from './load-balancer.js'
13
+ export { StreamBuffer } from './stream-buffer.js'
@@ -0,0 +1,27 @@
1
+ /**
2
+ * StreamBuffer (F-REL-06) — accumulate a provider stream for post-interceptors.
3
+ *
4
+ * Post-interceptors (guardrails, PII restore, audit) need the *complete*
5
+ * response, so a streamed reply is buffered in full before the post pipeline
6
+ * runs and before any chunk reaches the caller. This trades first-token latency
7
+ * for the guarantee that every interceptor sees — and can rewrite or block — the
8
+ * whole response.
9
+ */
10
+ export class StreamBuffer {
11
+ private readonly parts: string[] = []
12
+
13
+ /** Add one streamed chunk. */
14
+ append(chunk: string): void {
15
+ this.parts.push(chunk)
16
+ }
17
+
18
+ /** The full buffered response so far. */
19
+ text(): string {
20
+ return this.parts.join('')
21
+ }
22
+
23
+ /** Total buffered length in characters. */
24
+ get length(): number {
25
+ return this.parts.reduce((n, p) => n + p.length, 0)
26
+ }
27
+ }
@@ -1,6 +1,6 @@
1
1
  /** ProviderAdapter interface and shared response-building helpers. */
2
2
 
3
- import { PricingProvider } from '../pricing.js'
3
+ import { PricingProvider, estimateTokens } from '../pricing.js'
4
4
  import { GavioRequest } from '../request.js'
5
5
  import { GavioResponse } from '../response.js'
6
6
  import { TokenUsage } from '../types.js'
@@ -10,6 +10,8 @@ export interface ProviderAdapter {
10
10
  readonly providerName: string
11
11
  complete(request: GavioRequest): Promise<GavioResponse>
12
12
  stream?(request: GavioRequest): AsyncIterable<string>
13
+ /** Build a response from a fully buffered stream (F-REL-06). */
14
+ buildStreamResponse?(request: GavioRequest, content: string, startedAt: number): GavioResponse
13
15
  healthCheck(): Promise<boolean>
14
16
  readonly reportedModelVersion?: string | null
15
17
  }
@@ -30,6 +32,24 @@ export abstract class BaseProviderAdapter implements ProviderAdapter {
30
32
  return null
31
33
  }
32
34
 
35
+ /**
36
+ * Build a response from a fully buffered stream (F-REL-06). Streamed chunks
37
+ * carry text only, so token usage is estimated from prompt + content.
38
+ */
39
+ buildStreamResponse(request: GavioRequest, content: string, startedAt: number): GavioResponse {
40
+ const usage = new TokenUsage(
41
+ estimateTokens(request.promptText()),
42
+ estimateTokens(content),
43
+ )
44
+ return this.buildResponse(
45
+ request,
46
+ content,
47
+ usage,
48
+ this.reportedModelVersion ?? request.model,
49
+ startedAt,
50
+ )
51
+ }
52
+
33
53
  protected buildResponse(
34
54
  request: GavioRequest,
35
55
  content: string,
package/src/request.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /** GavioRequest — the canonical, provider-agnostic request model. */
2
2
 
3
3
  import { newTraceId } from './ids.js'
4
- import { coerceProvider } from './types.js'
5
- import type { Message, Provider } from './types.js'
4
+ import { coerceProvider, PromptLineage } from './types.js'
5
+ import type { Message, PromptLineageInit, Provider } from './types.js'
6
6
 
7
7
  export interface GavioRequestInit {
8
8
  messages: Message[]
@@ -14,6 +14,7 @@ export interface GavioRequestInit {
14
14
  sessionId?: string | null
15
15
  options?: Record<string, unknown>
16
16
  metadata?: Record<string, unknown>
17
+ lineage?: PromptLineage | PromptLineageInit | null
17
18
  }
18
19
 
19
20
  /**
@@ -31,6 +32,7 @@ export class GavioRequest {
31
32
  sessionId: string | null
32
33
  options: Record<string, unknown>
33
34
  metadata: Record<string, unknown>
35
+ lineage: PromptLineage | null
34
36
 
35
37
  constructor(init: GavioRequestInit) {
36
38
  this.messages = init.messages
@@ -42,6 +44,7 @@ export class GavioRequest {
42
44
  this.sessionId = init.sessionId ?? null
43
45
  this.options = init.options ?? {}
44
46
  this.metadata = init.metadata ?? {}
47
+ this.lineage = init.lineage != null ? PromptLineage.from(init.lineage) : null
45
48
  }
46
49
 
47
50
  get temperature(): number {
@@ -71,6 +74,7 @@ export class GavioRequest {
71
74
  sessionId: this.sessionId,
72
75
  options: { ...this.options },
73
76
  metadata: { ...this.metadata },
77
+ lineage: this.lineage,
74
78
  })
75
79
  }
76
80
  }
package/src/types.ts CHANGED
@@ -81,3 +81,80 @@ export class TokenUsage {
81
81
  }
82
82
  }
83
83
  }
84
+
85
+ export interface RagChunkInit {
86
+ source: string
87
+ chunkId?: string | null
88
+ score?: number | null
89
+ }
90
+
91
+ /**
92
+ * A single retrieved source that contributed to a prompt. Carries a *reference*
93
+ * to the source — never the retrieved text — so prompt lineage stays within the
94
+ * audit record's metadata-only contract.
95
+ */
96
+ export class RagChunk {
97
+ readonly source: string
98
+ readonly chunkId: string | null
99
+ readonly score: number | null
100
+
101
+ constructor(init: RagChunkInit) {
102
+ this.source = init.source
103
+ this.chunkId = init.chunkId ?? null
104
+ this.score = init.score ?? null
105
+ }
106
+
107
+ toJSON(): { source: string; chunkId: string | null; score: number | null } {
108
+ return { source: this.source, chunkId: this.chunkId, score: this.score }
109
+ }
110
+ }
111
+
112
+ export interface PromptLineageInit {
113
+ templateId?: string | null
114
+ templateVersion?: string | null
115
+ variables?: Record<string, unknown>
116
+ ragChunks?: Array<RagChunk | RagChunkInit>
117
+ }
118
+
119
+ /**
120
+ * Provenance for a rendered prompt (F-OBS-04): the template, the variable
121
+ * bindings interpolated into it, and the RAG chunk sources retrieved for it.
122
+ *
123
+ * Attached to a GavioRequest by the caller and copied into the AuditRecord so
124
+ * any prompt can be reconstructed and debugged. RAG chunk text is never stored
125
+ * — only source references (see {@link RagChunk}).
126
+ */
127
+ export class PromptLineage {
128
+ readonly templateId: string | null
129
+ readonly templateVersion: string | null
130
+ readonly variables: Record<string, unknown>
131
+ readonly ragChunks: RagChunk[]
132
+
133
+ constructor(init: PromptLineageInit = {}) {
134
+ this.templateId = init.templateId ?? null
135
+ this.templateVersion = init.templateVersion ?? null
136
+ this.variables = init.variables ?? {}
137
+ this.ragChunks = (init.ragChunks ?? []).map((c) =>
138
+ c instanceof RagChunk ? c : new RagChunk(c),
139
+ )
140
+ }
141
+
142
+ /** Coerce a PromptLineage instance or plain init object into a PromptLineage. */
143
+ static from(value: PromptLineage | PromptLineageInit): PromptLineage {
144
+ return value instanceof PromptLineage ? value : new PromptLineage(value)
145
+ }
146
+
147
+ toJSON(): {
148
+ templateId: string | null
149
+ templateVersion: string | null
150
+ variables: Record<string, unknown>
151
+ ragChunks: Array<{ source: string; chunkId: string | null; score: number | null }>
152
+ } {
153
+ return {
154
+ templateId: this.templateId,
155
+ templateVersion: this.templateVersion,
156
+ variables: this.variables,
157
+ ragChunks: this.ragChunks.map((c) => c.toJSON()),
158
+ }
159
+ }
160
+ }