gavio 0.1.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.
- package/README.md +95 -0
- package/dist/cjs/context.js +47 -0
- package/dist/cjs/errors.js +57 -0
- package/dist/cjs/gateway.js +127 -0
- package/dist/cjs/ids.js +60 -0
- package/dist/cjs/index.js +49 -0
- package/dist/cjs/interceptors/audit/index.js +12 -0
- package/dist/cjs/interceptors/audit/interceptor.js +77 -0
- package/dist/cjs/interceptors/audit/record.js +107 -0
- package/dist/cjs/interceptors/audit/sink.js +3 -0
- package/dist/cjs/interceptors/audit/sinks/index.js +5 -0
- package/dist/cjs/interceptors/audit/sinks/stdout.js +33 -0
- package/dist/cjs/interceptors/base.js +7 -0
- package/dist/cjs/interceptors/cache/backend.js +9 -0
- package/dist/cjs/interceptors/cache/backends/index.js +5 -0
- package/dist/cjs/interceptors/cache/backends/memory.js +53 -0
- package/dist/cjs/interceptors/cache/index.js +9 -0
- package/dist/cjs/interceptors/chain.js +57 -0
- package/dist/cjs/interceptors/index.js +18 -0
- package/dist/cjs/interceptors/pii/context.js +25 -0
- package/dist/cjs/interceptors/pii/guard.js +161 -0
- package/dist/cjs/interceptors/pii/index.js +28 -0
- package/dist/cjs/interceptors/pii/match.js +21 -0
- package/dist/cjs/interceptors/pii/scanner.js +31 -0
- package/dist/cjs/interceptors/pii/scanners/bsn.js +41 -0
- package/dist/cjs/interceptors/pii/scanners/credit-card.js +51 -0
- package/dist/cjs/interceptors/pii/scanners/email.js +26 -0
- package/dist/cjs/interceptors/pii/scanners/iban.js +58 -0
- package/dist/cjs/interceptors/pii/scanners/index.js +45 -0
- package/dist/cjs/interceptors/pii/scanners/ip-address.js +36 -0
- package/dist/cjs/interceptors/pii/scanners/phone.js +37 -0
- package/dist/cjs/interceptors/pii/scanners/secret.js +46 -0
- package/dist/cjs/interceptors/pii/scanners/ssn.js +28 -0
- package/dist/cjs/interceptors/reliability/fallback.js +53 -0
- package/dist/cjs/interceptors/reliability/index.js +11 -0
- package/dist/cjs/interceptors/reliability/retry.js +69 -0
- package/dist/cjs/interceptors/reliability/timeout.js +41 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/pricing.js +70 -0
- package/dist/cjs/providers/anthropic.js +80 -0
- package/dist/cjs/providers/base.js +30 -0
- package/dist/cjs/providers/http.js +42 -0
- package/dist/cjs/providers/index.js +34 -0
- package/dist/cjs/providers/mock.js +54 -0
- package/dist/cjs/providers/openai.js +63 -0
- package/dist/cjs/request.js +60 -0
- package/dist/cjs/response.js +55 -0
- package/dist/cjs/testing/harness.js +70 -0
- package/dist/cjs/testing/index.js +8 -0
- package/dist/cjs/types.js +61 -0
- package/dist/esm/context.d.ts +33 -0
- package/dist/esm/context.js +43 -0
- package/dist/esm/errors.d.ts +36 -0
- package/dist/esm/errors.js +44 -0
- package/dist/esm/gateway.d.ts +54 -0
- package/dist/esm/gateway.js +123 -0
- package/dist/esm/ids.d.ts +11 -0
- package/dist/esm/ids.js +56 -0
- package/dist/esm/index.d.ts +25 -0
- package/dist/esm/index.js +20 -0
- package/dist/esm/interceptors/audit/index.d.ts +7 -0
- package/dist/esm/interceptors/audit/index.js +3 -0
- package/dist/esm/interceptors/audit/interceptor.d.ts +11 -0
- package/dist/esm/interceptors/audit/interceptor.js +72 -0
- package/dist/esm/interceptors/audit/record.d.ts +66 -0
- package/dist/esm/interceptors/audit/record.js +103 -0
- package/dist/esm/interceptors/audit/sink.d.ts +8 -0
- package/dist/esm/interceptors/audit/sink.js +2 -0
- package/dist/esm/interceptors/audit/sinks/index.d.ts +2 -0
- package/dist/esm/interceptors/audit/sinks/index.js +1 -0
- package/dist/esm/interceptors/audit/sinks/stdout.d.ts +8 -0
- package/dist/esm/interceptors/audit/sinks/stdout.js +30 -0
- package/dist/esm/interceptors/base.d.ts +37 -0
- package/dist/esm/interceptors/base.js +4 -0
- package/dist/esm/interceptors/cache/backend.d.ts +14 -0
- package/dist/esm/interceptors/cache/backend.js +8 -0
- package/dist/esm/interceptors/cache/backends/index.d.ts +2 -0
- package/dist/esm/interceptors/cache/backends/index.js +1 -0
- package/dist/esm/interceptors/cache/backends/memory.d.ts +7 -0
- package/dist/esm/interceptors/cache/backends/memory.js +50 -0
- package/dist/esm/interceptors/cache/index.d.ts +7 -0
- package/dist/esm/interceptors/cache/index.js +5 -0
- package/dist/esm/interceptors/chain.d.ts +17 -0
- package/dist/esm/interceptors/chain.js +53 -0
- package/dist/esm/interceptors/index.d.ts +8 -0
- package/dist/esm/interceptors/index.js +7 -0
- package/dist/esm/interceptors/pii/context.d.ts +15 -0
- package/dist/esm/interceptors/pii/context.js +21 -0
- package/dist/esm/interceptors/pii/guard.d.ts +30 -0
- package/dist/esm/interceptors/pii/guard.js +157 -0
- package/dist/esm/interceptors/pii/index.d.ts +10 -0
- package/dist/esm/interceptors/pii/index.js +7 -0
- package/dist/esm/interceptors/pii/match.d.ts +26 -0
- package/dist/esm/interceptors/pii/match.js +17 -0
- package/dist/esm/interceptors/pii/scanner.d.ts +32 -0
- package/dist/esm/interceptors/pii/scanner.js +26 -0
- package/dist/esm/interceptors/pii/scanners/bsn.d.ts +5 -0
- package/dist/esm/interceptors/pii/scanners/bsn.js +37 -0
- package/dist/esm/interceptors/pii/scanners/credit-card.d.ts +4 -0
- package/dist/esm/interceptors/pii/scanners/credit-card.js +47 -0
- package/dist/esm/interceptors/pii/scanners/email.d.ts +3 -0
- package/dist/esm/interceptors/pii/scanners/email.js +23 -0
- package/dist/esm/interceptors/pii/scanners/iban.d.ts +5 -0
- package/dist/esm/interceptors/pii/scanners/iban.js +54 -0
- package/dist/esm/interceptors/pii/scanners/index.d.ts +13 -0
- package/dist/esm/interceptors/pii/scanners/index.js +30 -0
- package/dist/esm/interceptors/pii/scanners/ip-address.d.ts +3 -0
- package/dist/esm/interceptors/pii/scanners/ip-address.js +33 -0
- package/dist/esm/interceptors/pii/scanners/phone.d.ts +6 -0
- package/dist/esm/interceptors/pii/scanners/phone.js +34 -0
- package/dist/esm/interceptors/pii/scanners/secret.d.ts +9 -0
- package/dist/esm/interceptors/pii/scanners/secret.js +43 -0
- package/dist/esm/interceptors/pii/scanners/ssn.d.ts +3 -0
- package/dist/esm/interceptors/pii/scanners/ssn.js +25 -0
- package/dist/esm/interceptors/reliability/fallback.d.ts +9 -0
- package/dist/esm/interceptors/reliability/fallback.js +50 -0
- package/dist/esm/interceptors/reliability/index.d.ts +7 -0
- package/dist/esm/interceptors/reliability/index.js +4 -0
- package/dist/esm/interceptors/reliability/retry.d.ts +13 -0
- package/dist/esm/interceptors/reliability/retry.js +66 -0
- package/dist/esm/interceptors/reliability/timeout.d.ts +9 -0
- package/dist/esm/interceptors/reliability/timeout.js +37 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/pricing.d.ts +19 -0
- package/dist/esm/pricing.js +65 -0
- package/dist/esm/providers/anthropic.d.ts +30 -0
- package/dist/esm/providers/anthropic.js +77 -0
- package/dist/esm/providers/base.d.ts +23 -0
- package/dist/esm/providers/base.js +28 -0
- package/dist/esm/providers/http.d.ts +8 -0
- package/dist/esm/providers/http.js +39 -0
- package/dist/esm/providers/index.d.ts +15 -0
- package/dist/esm/providers/index.js +25 -0
- package/dist/esm/providers/mock.d.ts +31 -0
- package/dist/esm/providers/mock.js +51 -0
- package/dist/esm/providers/openai.d.ts +26 -0
- package/dist/esm/providers/openai.js +60 -0
- package/dist/esm/request.d.ts +36 -0
- package/dist/esm/request.js +56 -0
- package/dist/esm/response.d.ts +38 -0
- package/dist/esm/response.js +51 -0
- package/dist/esm/testing/harness.d.ts +37 -0
- package/dist/esm/testing/harness.js +66 -0
- package/dist/esm/testing/index.d.ts +5 -0
- package/dist/esm/testing/index.js +3 -0
- package/dist/esm/types.d.ts +58 -0
- package/dist/esm/types.js +56 -0
- package/package.json +115 -0
- package/src/context.ts +57 -0
- package/src/errors.ts +47 -0
- package/src/gateway.ts +174 -0
- package/src/ids.ts +69 -0
- package/src/index.ts +52 -0
- package/src/interceptors/audit/index.ts +7 -0
- package/src/interceptors/audit/interceptor.ts +93 -0
- package/src/interceptors/audit/record.ts +138 -0
- package/src/interceptors/audit/sink.ts +10 -0
- package/src/interceptors/audit/sinks/index.ts +2 -0
- package/src/interceptors/audit/sinks/stdout.ts +42 -0
- package/src/interceptors/base.ts +58 -0
- package/src/interceptors/cache/backend.ts +15 -0
- package/src/interceptors/cache/backends/index.ts +2 -0
- package/src/interceptors/cache/backends/memory.ts +68 -0
- package/src/interceptors/cache/index.ts +8 -0
- package/src/interceptors/chain.ts +65 -0
- package/src/interceptors/index.ts +9 -0
- package/src/interceptors/pii/context.ts +24 -0
- package/src/interceptors/pii/guard.ts +201 -0
- package/src/interceptors/pii/index.ts +21 -0
- package/src/interceptors/pii/match.ts +43 -0
- package/src/interceptors/pii/scanner.ts +54 -0
- package/src/interceptors/pii/scanners/bsn.ts +44 -0
- package/src/interceptors/pii/scanners/credit-card.ts +52 -0
- package/src/interceptors/pii/scanners/email.ts +31 -0
- package/src/interceptors/pii/scanners/iban.ts +60 -0
- package/src/interceptors/pii/scanners/index.ts +35 -0
- package/src/interceptors/pii/scanners/ip-address.ts +41 -0
- package/src/interceptors/pii/scanners/phone.ts +46 -0
- package/src/interceptors/pii/scanners/secret.ts +51 -0
- package/src/interceptors/pii/scanners/ssn.ts +33 -0
- package/src/interceptors/reliability/fallback.ts +66 -0
- package/src/interceptors/reliability/index.ts +8 -0
- package/src/interceptors/reliability/retry.ts +97 -0
- package/src/interceptors/reliability/timeout.ts +53 -0
- package/src/pricing.ts +72 -0
- package/src/providers/anthropic.ts +113 -0
- package/src/providers/base.ts +52 -0
- package/src/providers/http.ts +50 -0
- package/src/providers/index.ts +39 -0
- package/src/providers/mock.ts +73 -0
- package/src/providers/openai.ts +94 -0
- package/src/request.ts +76 -0
- package/src/response.ts +73 -0
- package/src/testing/harness.ts +98 -0
- package/src/testing/index.ts +6 -0
- package/src/types.ts +83 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** US Social Security Number scanner. */
|
|
2
|
+
|
|
3
|
+
import type { ScanContext } from '../context.js'
|
|
4
|
+
import { makeMatch } from '../match.js'
|
|
5
|
+
import type { PiiMatch } from '../match.js'
|
|
6
|
+
import type { PiiScanner } from '../scanner.js'
|
|
7
|
+
|
|
8
|
+
// AAA-GG-SSSS with hyphens or spaces. Requires a separator to avoid colliding
|
|
9
|
+
// with bare 9-digit numbers (handled by the BSN scanner / others).
|
|
10
|
+
const SSN = /\b(?!000|666|9\d\d)\d{3}[ -](?!00)\d{2}[ -](?!0000)\d{4}\b/g
|
|
11
|
+
|
|
12
|
+
export function ssnScanner(): PiiScanner {
|
|
13
|
+
return {
|
|
14
|
+
entityType: 'SSN',
|
|
15
|
+
tier: 1,
|
|
16
|
+
scan(text: string, ctx: ScanContext): PiiMatch[] {
|
|
17
|
+
const out: PiiMatch[] = []
|
|
18
|
+
for (const m of text.matchAll(SSN)) {
|
|
19
|
+
const idx = ctx.nextIndex('SSN')
|
|
20
|
+
out.push(
|
|
21
|
+
makeMatch({
|
|
22
|
+
entityType: 'SSN',
|
|
23
|
+
start: m.index,
|
|
24
|
+
end: m.index + m[0].length,
|
|
25
|
+
value: m[0],
|
|
26
|
+
replacement: `[SSN_${idx}]`,
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** fallbackChain (F-REL-02) — route to a secondary provider on failure. */
|
|
2
|
+
|
|
3
|
+
import type { InterceptorContext } from '../../context.js'
|
|
4
|
+
import { ProviderError } from '../../errors.js'
|
|
5
|
+
import type { ProviderAdapter } from '../../providers/base.js'
|
|
6
|
+
import type { GavioRequest } from '../../request.js'
|
|
7
|
+
import type { GavioResponse } from '../../response.js'
|
|
8
|
+
import { coerceProvider } from '../../types.js'
|
|
9
|
+
import type { Executor, ExecutorPolicy } from '../base.js'
|
|
10
|
+
|
|
11
|
+
export interface FallbackChainOptions {
|
|
12
|
+
/** Provider adapters to try, in order, when the primary call fails. */
|
|
13
|
+
fallbacks: ProviderAdapter[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Try the primary executor; on a provider error, try fallback adapters.
|
|
18
|
+
*
|
|
19
|
+
* Each fallback is a provider adapter (anything with an async `complete`). The
|
|
20
|
+
* request's provider/model are rewritten per fallback so the audit record
|
|
21
|
+
* reflects which provider actually answered.
|
|
22
|
+
*/
|
|
23
|
+
class FallbackChain implements ExecutorPolicy {
|
|
24
|
+
readonly name = 'fallback'
|
|
25
|
+
readonly isExecutorPolicy = true as const
|
|
26
|
+
readonly dryRunSafe = true
|
|
27
|
+
|
|
28
|
+
private readonly fallbacks: ProviderAdapter[]
|
|
29
|
+
|
|
30
|
+
constructor(options: FallbackChainOptions) {
|
|
31
|
+
if (!options.fallbacks || options.fallbacks.length === 0) {
|
|
32
|
+
throw new Error('fallbackChain requires at least one fallback adapter')
|
|
33
|
+
}
|
|
34
|
+
this.fallbacks = options.fallbacks
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async around(
|
|
38
|
+
request: GavioRequest,
|
|
39
|
+
ctx: InterceptorContext,
|
|
40
|
+
callNext: Executor,
|
|
41
|
+
): Promise<GavioResponse> {
|
|
42
|
+
ctx.markFired(this.name)
|
|
43
|
+
try {
|
|
44
|
+
return await callNext(request)
|
|
45
|
+
} catch (primaryError) {
|
|
46
|
+
if (!(primaryError instanceof ProviderError)) throw primaryError
|
|
47
|
+
let lastError: unknown = primaryError
|
|
48
|
+
for (const adapter of this.fallbacks) {
|
|
49
|
+
try {
|
|
50
|
+
const rerouted = request.copyWithMessages(request.messages)
|
|
51
|
+
rerouted.provider = coerceProvider(adapter.providerName)
|
|
52
|
+
return await adapter.complete(rerouted)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!(error instanceof ProviderError)) throw error
|
|
55
|
+
lastError = error
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw lastError
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Factory: build a fallback policy. */
|
|
64
|
+
export function fallbackChain(options: FallbackChainOptions): ExecutorPolicy {
|
|
65
|
+
return new FallbackChain(options)
|
|
66
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Reliability policies (F-REL-01, F-REL-02, F-REL-07). */
|
|
2
|
+
|
|
3
|
+
export { retryInterceptor } from './retry.js'
|
|
4
|
+
export type { RetryInterceptorOptions } from './retry.js'
|
|
5
|
+
export { timeoutPolicy, timeout } from './timeout.js'
|
|
6
|
+
export type { TimeoutPolicyOptions } from './timeout.js'
|
|
7
|
+
export { fallbackChain } from './fallback.js'
|
|
8
|
+
export type { FallbackChainOptions } from './fallback.js'
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** retryInterceptor (F-REL-01) — exponential backoff with jitter. */
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import type { InterceptorContext } from '../../context.js'
|
|
5
|
+
import {
|
|
6
|
+
ProviderUnavailableError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
ServerError,
|
|
9
|
+
TimeoutError,
|
|
10
|
+
} from '../../errors.js'
|
|
11
|
+
import type { GavioRequest } from '../../request.js'
|
|
12
|
+
import type { GavioResponse } from '../../response.js'
|
|
13
|
+
import type { Executor, ExecutorPolicy } from '../base.js'
|
|
14
|
+
|
|
15
|
+
/** Default predicate: retry transient provider errors. */
|
|
16
|
+
function defaultRetryable(error: unknown): boolean {
|
|
17
|
+
return (
|
|
18
|
+
error instanceof RateLimitError ||
|
|
19
|
+
error instanceof TimeoutError ||
|
|
20
|
+
error instanceof ServerError ||
|
|
21
|
+
error instanceof ProviderUnavailableError
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RetryInterceptorOptions {
|
|
26
|
+
maxAttempts?: number
|
|
27
|
+
baseDelayMs?: number
|
|
28
|
+
maxDelayMs?: number
|
|
29
|
+
jitter?: boolean
|
|
30
|
+
retryOn?: (error: unknown) => boolean
|
|
31
|
+
/** Override the sleep implementation (used in tests). */
|
|
32
|
+
sleep?: (ms: number) => Promise<void>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
36
|
+
new Promise((resolve) => setTimeout(resolve, ms))
|
|
37
|
+
|
|
38
|
+
class RetryInterceptor implements ExecutorPolicy {
|
|
39
|
+
readonly name = 'retry'
|
|
40
|
+
readonly isExecutorPolicy = true as const
|
|
41
|
+
readonly dryRunSafe = true
|
|
42
|
+
|
|
43
|
+
private readonly maxAttempts: number
|
|
44
|
+
private readonly baseDelayMs: number
|
|
45
|
+
private readonly maxDelayMs: number
|
|
46
|
+
private readonly jitter: boolean
|
|
47
|
+
private readonly retryOn: (error: unknown) => boolean
|
|
48
|
+
private readonly sleep: (ms: number) => Promise<void>
|
|
49
|
+
|
|
50
|
+
constructor(options: RetryInterceptorOptions = {}) {
|
|
51
|
+
const maxAttempts = options.maxAttempts ?? 3
|
|
52
|
+
if (maxAttempts < 1) throw new Error('maxAttempts must be >= 1')
|
|
53
|
+
this.maxAttempts = maxAttempts
|
|
54
|
+
this.baseDelayMs = options.baseDelayMs ?? 500
|
|
55
|
+
this.maxDelayMs = options.maxDelayMs ?? 10_000
|
|
56
|
+
this.jitter = options.jitter ?? true
|
|
57
|
+
this.retryOn = options.retryOn ?? defaultRetryable
|
|
58
|
+
this.sleep = options.sleep ?? defaultSleep
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async around(
|
|
62
|
+
request: GavioRequest,
|
|
63
|
+
ctx: InterceptorContext,
|
|
64
|
+
callNext: Executor,
|
|
65
|
+
): Promise<GavioResponse> {
|
|
66
|
+
ctx.markFired(this.name)
|
|
67
|
+
let lastError: unknown
|
|
68
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
69
|
+
try {
|
|
70
|
+
return await callNext(request)
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (!this.retryOn(error)) throw error
|
|
73
|
+
lastError = error
|
|
74
|
+
if (attempt >= this.maxAttempts) break
|
|
75
|
+
await this.sleep(this.delayMs(attempt))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw lastError
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private delayMs(attempt: number): number {
|
|
82
|
+
// Exponential: base * 2^(attempt-1), capped, with optional full jitter.
|
|
83
|
+
const raw = this.baseDelayMs * 2 ** (attempt - 1)
|
|
84
|
+
let capped = Math.min(raw, this.maxDelayMs)
|
|
85
|
+
if (this.jitter) {
|
|
86
|
+
// full jitter in [0, capped] using crypto bytes (no global RNG state)
|
|
87
|
+
const frac = randomBytes(2).readUInt16BE(0) / 0xffff
|
|
88
|
+
capped *= frac
|
|
89
|
+
}
|
|
90
|
+
return capped
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Factory: build a retry policy. */
|
|
95
|
+
export function retryInterceptor(options: RetryInterceptorOptions = {}): ExecutorPolicy {
|
|
96
|
+
return new RetryInterceptor(options)
|
|
97
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** timeoutPolicy (F-REL-07) — per-request timeout enforcement. */
|
|
2
|
+
|
|
3
|
+
import type { InterceptorContext } from '../../context.js'
|
|
4
|
+
import { TimeoutError } from '../../errors.js'
|
|
5
|
+
import type { GavioRequest } from '../../request.js'
|
|
6
|
+
import type { GavioResponse } from '../../response.js'
|
|
7
|
+
import type { Executor, ExecutorPolicy } from '../base.js'
|
|
8
|
+
|
|
9
|
+
export interface TimeoutPolicyOptions {
|
|
10
|
+
timeoutSeconds?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class TimeoutPolicy implements ExecutorPolicy {
|
|
14
|
+
readonly name = 'timeout'
|
|
15
|
+
readonly isExecutorPolicy = true as const
|
|
16
|
+
readonly dryRunSafe = true
|
|
17
|
+
|
|
18
|
+
private readonly timeoutSeconds: number
|
|
19
|
+
|
|
20
|
+
constructor(options: TimeoutPolicyOptions = {}) {
|
|
21
|
+
const timeoutSeconds = options.timeoutSeconds ?? 30.0
|
|
22
|
+
if (timeoutSeconds <= 0) throw new Error('timeoutSeconds must be > 0')
|
|
23
|
+
this.timeoutSeconds = timeoutSeconds
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async around(
|
|
27
|
+
request: GavioRequest,
|
|
28
|
+
ctx: InterceptorContext,
|
|
29
|
+
callNext: Executor,
|
|
30
|
+
): Promise<GavioResponse> {
|
|
31
|
+
ctx.markFired(this.name)
|
|
32
|
+
const ms = this.timeoutSeconds * 1000
|
|
33
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
34
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
35
|
+
timer = setTimeout(() => {
|
|
36
|
+
reject(new TimeoutError(`Request exceeded ${this.timeoutSeconds}s timeout`))
|
|
37
|
+
}, ms)
|
|
38
|
+
})
|
|
39
|
+
try {
|
|
40
|
+
return await Promise.race([callNext(request), timeout])
|
|
41
|
+
} finally {
|
|
42
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Factory: build a timeout policy. */
|
|
48
|
+
export function timeoutPolicy(options: TimeoutPolicyOptions = {}): ExecutorPolicy {
|
|
49
|
+
return new TimeoutPolicy(options)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Alias matching the SDK plan naming (`timeout`). */
|
|
53
|
+
export const timeout = timeoutPolicy
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token cost tracking (F-GOV-01).
|
|
3
|
+
*
|
|
4
|
+
* Prices are USD per 1,000 tokens, sourced from public provider pricing and
|
|
5
|
+
* overridable. Unknown models price at zero (warned once) rather than guessing.
|
|
6
|
+
* Prices are intentionally data, not code — update the table, not the estimator.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TokenUsage } from './types.js'
|
|
10
|
+
|
|
11
|
+
/** model -> [inputPer1kUsd, outputPer1kUsd] */
|
|
12
|
+
const DEFAULT_PRICES: Record<string, [number, number]> = {
|
|
13
|
+
// OpenAI
|
|
14
|
+
'gpt-4o': [0.0025, 0.01],
|
|
15
|
+
'gpt-4o-mini': [0.00015, 0.0006],
|
|
16
|
+
o1: [0.015, 0.06],
|
|
17
|
+
'o1-mini': [0.0011, 0.0044],
|
|
18
|
+
// Anthropic
|
|
19
|
+
'claude-sonnet-4-6': [0.003, 0.015],
|
|
20
|
+
'claude-sonnet-4-20250514': [0.003, 0.015],
|
|
21
|
+
'claude-haiku-4-5': [0.0008, 0.004],
|
|
22
|
+
'claude-opus-4-1': [0.015, 0.075],
|
|
23
|
+
// Local / mock are free.
|
|
24
|
+
mock: [0.0, 0.0],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Estimates request cost from token usage and a model price table. */
|
|
28
|
+
export class PricingProvider {
|
|
29
|
+
private prices: Record<string, [number, number]>
|
|
30
|
+
private warned = new Set<string>()
|
|
31
|
+
|
|
32
|
+
constructor(prices?: Record<string, [number, number]>) {
|
|
33
|
+
this.prices = { ...DEFAULT_PRICES, ...(prices ?? {}) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setPrice(model: string, inputPer1k: number, outputPer1k: number): void {
|
|
37
|
+
this.prices[model] = [inputPer1k, outputPer1k]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
rates(model: string): [number, number] {
|
|
41
|
+
const rate = this.prices[model]
|
|
42
|
+
if (rate !== undefined) return rate
|
|
43
|
+
// try a prefix match (e.g. "gpt-4o-2024-..." -> "gpt-4o")
|
|
44
|
+
for (const [known, value] of Object.entries(this.prices)) {
|
|
45
|
+
if (model.startsWith(known)) return value
|
|
46
|
+
}
|
|
47
|
+
if (!this.warned.has(model)) {
|
|
48
|
+
// eslint-disable-next-line no-console
|
|
49
|
+
console.warn(`[gavio:pricing] no pricing for model '${model}'; treating as free`)
|
|
50
|
+
this.warned.add(model)
|
|
51
|
+
}
|
|
52
|
+
return [0.0, 0.0]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
estimate(model: string, usage: TokenUsage): number {
|
|
56
|
+
const [inRate, outRate] = this.rates(model)
|
|
57
|
+
let cost = (usage.promptTokens / 1000.0) * inRate
|
|
58
|
+
cost += (usage.completionTokens / 1000.0) * outRate
|
|
59
|
+
return roundTo(cost, 8)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function roundTo(value: number, decimals: number): number {
|
|
64
|
+
const factor = 10 ** decimals
|
|
65
|
+
return Math.round(value * factor) / factor
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Rough token estimate (~4 chars/token) for providers without a tokenizer. */
|
|
69
|
+
export function estimateTokens(text: string): number {
|
|
70
|
+
if (!text) return 0
|
|
71
|
+
return Math.max(1, Math.floor(text.length / 4))
|
|
72
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** anthropicAdapter — Messages API (Claude Sonnet, Haiku, Opus). */
|
|
2
|
+
|
|
3
|
+
import { ConfigurationError } from '../errors.js'
|
|
4
|
+
import type { PricingProvider } from '../pricing.js'
|
|
5
|
+
import type { GavioRequest } from '../request.js'
|
|
6
|
+
import type { GavioResponse } from '../response.js'
|
|
7
|
+
import { TokenUsage } from '../types.js'
|
|
8
|
+
import type { Message } from '../types.js'
|
|
9
|
+
import { BaseProviderAdapter } from './base.js'
|
|
10
|
+
import { postJson } from './http.js'
|
|
11
|
+
|
|
12
|
+
const DEFAULT_BASE_URL = 'https://api.anthropic.com/v1'
|
|
13
|
+
const API_VERSION = '2023-06-01'
|
|
14
|
+
|
|
15
|
+
export interface AnthropicAdapterOptions {
|
|
16
|
+
apiKey?: string
|
|
17
|
+
baseUrl?: string
|
|
18
|
+
timeoutMs?: number
|
|
19
|
+
pricing?: PricingProvider
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Talks to the Anthropic Messages endpoint. Anthropic splits the system prompt
|
|
24
|
+
* from the message list, so any `role === "system"` messages are extracted into
|
|
25
|
+
* the `system` field.
|
|
26
|
+
*/
|
|
27
|
+
class AnthropicAdapter extends BaseProviderAdapter {
|
|
28
|
+
private readonly apiKey: string | undefined
|
|
29
|
+
private readonly baseUrl: string
|
|
30
|
+
private readonly timeoutSeconds: number
|
|
31
|
+
|
|
32
|
+
constructor(options: AnthropicAdapterOptions = {}) {
|
|
33
|
+
super(options.pricing)
|
|
34
|
+
this.apiKey = options.apiKey ?? process.env['ANTHROPIC_API_KEY']
|
|
35
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
36
|
+
this.timeoutSeconds = (options.timeoutMs ?? 30_000) / 1000
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get providerName(): string {
|
|
40
|
+
return 'anthropic'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private headers(): Record<string, string> {
|
|
44
|
+
if (!this.apiKey) {
|
|
45
|
+
throw new ConfigurationError(
|
|
46
|
+
'ANTHROPIC_API_KEY not set (pass apiKey or set the env var)',
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
'x-api-key': this.apiKey,
|
|
51
|
+
'anthropic-version': API_VERSION,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private static splitSystem(messages: Message[]): [string | null, Message[]] {
|
|
56
|
+
const systemParts = messages
|
|
57
|
+
.filter((m) => m.role === 'system')
|
|
58
|
+
.map((m) => m.content)
|
|
59
|
+
const chat = messages.filter((m) => m.role !== 'system')
|
|
60
|
+
const system = systemParts.length > 0 ? systemParts.join('\n') : null
|
|
61
|
+
return [system, chat]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async complete(request: GavioRequest): Promise<GavioResponse> {
|
|
65
|
+
const started = performance.now()
|
|
66
|
+
const [system, chat] = AnthropicAdapter.splitSystem(request.messages)
|
|
67
|
+
const payload: Record<string, unknown> = {
|
|
68
|
+
model: request.model,
|
|
69
|
+
messages: chat,
|
|
70
|
+
max_tokens: request.maxTokens,
|
|
71
|
+
temperature: request.temperature,
|
|
72
|
+
}
|
|
73
|
+
if (system) payload['system'] = system
|
|
74
|
+
|
|
75
|
+
const data = await postJson(
|
|
76
|
+
`${this.baseUrl}/messages`,
|
|
77
|
+
payload,
|
|
78
|
+
this.headers(),
|
|
79
|
+
this.timeoutSeconds,
|
|
80
|
+
)
|
|
81
|
+
const blocks = (data['content'] as Array<Record<string, unknown>>) ?? []
|
|
82
|
+
const content = blocks
|
|
83
|
+
.filter((b) => b['type'] === 'text')
|
|
84
|
+
.map((b) => (b['text'] as string) ?? '')
|
|
85
|
+
.join('')
|
|
86
|
+
const usageData = (data['usage'] as Record<string, number>) ?? {}
|
|
87
|
+
const usage = new TokenUsage(
|
|
88
|
+
usageData['input_tokens'] ?? 0,
|
|
89
|
+
usageData['output_tokens'] ?? 0,
|
|
90
|
+
)
|
|
91
|
+
return this.buildResponse(
|
|
92
|
+
request,
|
|
93
|
+
content,
|
|
94
|
+
usage,
|
|
95
|
+
(data['model'] as string) ?? request.model,
|
|
96
|
+
started,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async healthCheck(): Promise<boolean> {
|
|
101
|
+
try {
|
|
102
|
+
this.headers()
|
|
103
|
+
return true
|
|
104
|
+
} catch {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Factory: build an Anthropic provider adapter. */
|
|
111
|
+
export function anthropicAdapter(options: AnthropicAdapterOptions = {}): AnthropicAdapter {
|
|
112
|
+
return new AnthropicAdapter(options)
|
|
113
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** ProviderAdapter interface and shared response-building helpers. */
|
|
2
|
+
|
|
3
|
+
import { PricingProvider } from '../pricing.js'
|
|
4
|
+
import { GavioRequest } from '../request.js'
|
|
5
|
+
import { GavioResponse } from '../response.js'
|
|
6
|
+
import { TokenUsage } from '../types.js'
|
|
7
|
+
|
|
8
|
+
/** Adapter to one LLM provider. */
|
|
9
|
+
export interface ProviderAdapter {
|
|
10
|
+
readonly providerName: string
|
|
11
|
+
complete(request: GavioRequest): Promise<GavioResponse>
|
|
12
|
+
stream?(request: GavioRequest): AsyncIterable<string>
|
|
13
|
+
healthCheck(): Promise<boolean>
|
|
14
|
+
readonly reportedModelVersion?: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Base class with a shared pricing provider and response builder. */
|
|
18
|
+
export abstract class BaseProviderAdapter implements ProviderAdapter {
|
|
19
|
+
protected readonly pricing: PricingProvider
|
|
20
|
+
|
|
21
|
+
constructor(pricing?: PricingProvider) {
|
|
22
|
+
this.pricing = pricing ?? new PricingProvider()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
abstract get providerName(): string
|
|
26
|
+
abstract complete(request: GavioRequest): Promise<GavioResponse>
|
|
27
|
+
abstract healthCheck(): Promise<boolean>
|
|
28
|
+
|
|
29
|
+
get reportedModelVersion(): string | null {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
protected buildResponse(
|
|
34
|
+
request: GavioRequest,
|
|
35
|
+
content: string,
|
|
36
|
+
usage: TokenUsage,
|
|
37
|
+
modelVersion: string,
|
|
38
|
+
startedAt: number,
|
|
39
|
+
): GavioResponse {
|
|
40
|
+
const latencyMs = Math.floor(performance.now() - startedAt)
|
|
41
|
+
return new GavioResponse({
|
|
42
|
+
traceId: request.traceId,
|
|
43
|
+
content,
|
|
44
|
+
model: request.model,
|
|
45
|
+
provider: this.providerName,
|
|
46
|
+
modelVersion: modelVersion || request.model,
|
|
47
|
+
usage,
|
|
48
|
+
costUsd: this.pricing.estimate(request.model, usage),
|
|
49
|
+
latencyMs,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Tiny JSON-over-HTTP helper built on native fetch (keeps core dependency-free). */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ProviderUnavailableError,
|
|
5
|
+
RateLimitError,
|
|
6
|
+
ServerError,
|
|
7
|
+
} from '../errors.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST `payload` as JSON and return the parsed response.
|
|
11
|
+
*
|
|
12
|
+
* Maps HTTP status families onto Gavio's transient error types so the
|
|
13
|
+
* retry/fallback policies can react.
|
|
14
|
+
*/
|
|
15
|
+
export async function postJson(
|
|
16
|
+
url: string,
|
|
17
|
+
payload: unknown,
|
|
18
|
+
headers: Record<string, string>,
|
|
19
|
+
timeoutSeconds = 30.0,
|
|
20
|
+
): Promise<Record<string, unknown>> {
|
|
21
|
+
const controller = new AbortController()
|
|
22
|
+
const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000)
|
|
23
|
+
let resp: Response
|
|
24
|
+
try {
|
|
25
|
+
resp = await fetch(url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
28
|
+
body: JSON.stringify(payload),
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
})
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
33
|
+
throw new ProviderUnavailableError(`network error: ${reason}`)
|
|
34
|
+
} finally {
|
|
35
|
+
clearTimeout(timer)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!resp.ok) {
|
|
39
|
+
const body = (await resp.text().catch(() => '')).slice(0, 200)
|
|
40
|
+
if (resp.status === 429) {
|
|
41
|
+
throw new RateLimitError(`429 from provider: ${body}`)
|
|
42
|
+
}
|
|
43
|
+
if (resp.status >= 500) {
|
|
44
|
+
throw new ServerError(`${resp.status} from provider: ${body}`)
|
|
45
|
+
}
|
|
46
|
+
throw new ProviderUnavailableError(`${resp.status} from provider: ${body}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (await resp.json()) as Record<string, unknown>
|
|
50
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Provider adapters and the provider registry. */
|
|
2
|
+
|
|
3
|
+
import { ConfigurationError } from '../errors.js'
|
|
4
|
+
import type { PricingProvider } from '../pricing.js'
|
|
5
|
+
import { Provider, coerceProvider } from '../types.js'
|
|
6
|
+
import { anthropicAdapter } from './anthropic.js'
|
|
7
|
+
import type { ProviderAdapter } from './base.js'
|
|
8
|
+
import { mockProvider } from './mock.js'
|
|
9
|
+
import { openaiAdapter } from './openai.js'
|
|
10
|
+
|
|
11
|
+
export type { ProviderAdapter } from './base.js'
|
|
12
|
+
export { BaseProviderAdapter } from './base.js'
|
|
13
|
+
export { mockProvider } from './mock.js'
|
|
14
|
+
export type { MockProviderOptions } from './mock.js'
|
|
15
|
+
export { openaiAdapter } from './openai.js'
|
|
16
|
+
export type { OpenAIAdapterOptions } from './openai.js'
|
|
17
|
+
export { anthropicAdapter } from './anthropic.js'
|
|
18
|
+
export type { AnthropicAdapterOptions } from './anthropic.js'
|
|
19
|
+
export { Provider } from '../types.js'
|
|
20
|
+
|
|
21
|
+
/** Instantiate the default adapter for a provider id. v0.1.0: OpenAI, Anthropic, Mock. */
|
|
22
|
+
export function buildAdapter(
|
|
23
|
+
provider: Provider | string,
|
|
24
|
+
pricing?: PricingProvider,
|
|
25
|
+
): ProviderAdapter {
|
|
26
|
+
const p = coerceProvider(provider)
|
|
27
|
+
switch (p) {
|
|
28
|
+
case Provider.OPENAI:
|
|
29
|
+
return openaiAdapter(pricing ? { pricing } : {})
|
|
30
|
+
case Provider.ANTHROPIC:
|
|
31
|
+
return anthropicAdapter(pricing ? { pricing } : {})
|
|
32
|
+
case Provider.MOCK:
|
|
33
|
+
return mockProvider(pricing ? { pricing } : {})
|
|
34
|
+
default:
|
|
35
|
+
throw new ConfigurationError(
|
|
36
|
+
`Provider '${p}' is not available in v0.1.0 (available: openai, anthropic, mock)`,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** mockProvider — deterministic, offline provider for dev mode and tests. */
|
|
2
|
+
|
|
3
|
+
import { PricingProvider, estimateTokens } from '../pricing.js'
|
|
4
|
+
import type { GavioRequest } from '../request.js'
|
|
5
|
+
import type { GavioResponse } from '../response.js'
|
|
6
|
+
import { TokenUsage } from '../types.js'
|
|
7
|
+
import { BaseProviderAdapter } from './base.js'
|
|
8
|
+
|
|
9
|
+
export interface MockProviderOptions {
|
|
10
|
+
response?: string | null
|
|
11
|
+
modelVersion?: string
|
|
12
|
+
pricing?: PricingProvider
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns a canned response without any network call.
|
|
17
|
+
*
|
|
18
|
+
* If `response` is null/undefined, it echoes the last user message so the
|
|
19
|
+
* pipeline (including PII restore) is observable end to end.
|
|
20
|
+
*/
|
|
21
|
+
class MockProvider extends BaseProviderAdapter {
|
|
22
|
+
private readonly response: string | null
|
|
23
|
+
private readonly modelVersion: string
|
|
24
|
+
|
|
25
|
+
constructor(options: MockProviderOptions = {}) {
|
|
26
|
+
super(options.pricing)
|
|
27
|
+
this.response = options.response ?? null
|
|
28
|
+
this.modelVersion = options.modelVersion ?? 'mock-1'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get providerName(): string {
|
|
32
|
+
return 'mock'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
override get reportedModelVersion(): string | null {
|
|
36
|
+
return this.modelVersion
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private contentFor(request: GavioRequest): string {
|
|
40
|
+
if (this.response !== null) return this.response
|
|
41
|
+
const lastUser = [...request.messages]
|
|
42
|
+
.reverse()
|
|
43
|
+
.find((m) => m.role === 'user')
|
|
44
|
+
return `[mock reply] ${lastUser?.content ?? ''}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async complete(request: GavioRequest): Promise<GavioResponse> {
|
|
48
|
+
const started = performance.now()
|
|
49
|
+
const content = this.contentFor(request)
|
|
50
|
+
const usage = new TokenUsage(
|
|
51
|
+
estimateTokens(request.promptText()),
|
|
52
|
+
estimateTokens(content),
|
|
53
|
+
)
|
|
54
|
+
return this.buildResponse(request, content, usage, this.modelVersion, started)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async *stream(request: GavioRequest): AsyncIterable<string> {
|
|
58
|
+
for (const token of this.contentFor(request).split(' ')) {
|
|
59
|
+
yield token + ' '
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async healthCheck(): Promise<boolean> {
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Factory: build a mock provider adapter. */
|
|
69
|
+
export function mockProvider(options: MockProviderOptions = {}): ProviderAdapterMock {
|
|
70
|
+
return new MockProvider(options)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type ProviderAdapterMock = MockProvider
|