dsh-llm-verifier 0.1.13 → 0.1.14
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/LICENSE +22 -22
- package/README.md +301 -301
- package/cordis.patch.yml +4 -4
- package/lib/client.js +176 -14
- package/lib/index.js +418 -43
- package/lib/types/client-i18n.d.ts +27 -0
- package/lib/types/client-i18n.js +18 -0
- package/lib/types/client.d.ts +15 -2
- package/lib/types/client.js +134 -9
- package/lib/types/config.d.ts +4 -0
- package/lib/types/config.js +5 -3
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.js +185 -4
- package/lib/types/plan-gate.d.ts +14 -0
- package/lib/types/plan-gate.js +70 -0
- package/lib/types/router.d.ts +10 -1
- package/lib/types/router.js +37 -7
- package/lib/types/session.js +25 -10
- package/lib/types/team-gate.d.ts +11 -0
- package/lib/types/team-gate.js +51 -0
- package/package.json +112 -112
- package/src/auto.test.ts +86 -86
- package/src/auto.ts +133 -133
- package/src/cache.ts +115 -115
- package/src/caller.test.ts +34 -34
- package/src/caller.ts +151 -151
- package/src/client-i18n.ts +18 -0
- package/src/client.tsx +385 -213
- package/src/config.ts +189 -183
- package/src/core.test.ts +71 -71
- package/src/core.ts +205 -205
- package/src/engine.test.ts +136 -136
- package/src/engine.ts +140 -140
- package/src/images.ts +33 -33
- package/src/index.ts +416 -247
- package/src/parity.test.ts +85 -85
- package/src/plan-gate.test.ts +56 -0
- package/src/plan-gate.ts +77 -0
- package/src/router.test.ts +107 -89
- package/src/router.ts +349 -309
- package/src/session.test.ts +53 -36
- package/src/session.ts +117 -103
- package/src/statistics.test.ts +80 -80
- package/src/statistics.ts +328 -328
- package/src/team-gate.test.ts +56 -0
- package/src/team-gate.ts +63 -0
- package/src/top-logprobs.test.ts +84 -84
- package/src/top-logprobs.ts +153 -153
- package/src/topic-storage.test.ts +31 -31
- package/src/topic-storage.ts +42 -42
package/src/cache.ts
CHANGED
|
@@ -1,115 +1,115 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
|
-
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
4
|
-
import type { UsageStats } from './caller.ts'
|
|
5
|
-
|
|
6
|
-
export interface CachedPairScore {
|
|
7
|
-
scoreA: number
|
|
8
|
-
scoreB: number
|
|
9
|
-
usage: UsageStats
|
|
10
|
-
scoringMode: 'top-logprobs' | 'explicit-tag'
|
|
11
|
-
createdAt: number
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface CacheDocument {
|
|
15
|
-
version: 1
|
|
16
|
-
entries: Record<string, CachedPairScore>
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function stableHash(value: unknown): string {
|
|
20
|
-
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function resolveCacheFile(cacheDir: string, cwd = process.cwd()): string {
|
|
24
|
-
const root = isAbsolute(cacheDir) ? cacheDir : resolve(cwd, cacheDir)
|
|
25
|
-
return join(root, 'scores-v1.json')
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function validUsage(value: unknown): value is UsageStats {
|
|
29
|
-
if (typeof value !== 'object' || value === null) return false
|
|
30
|
-
const row = value as Record<string, unknown>
|
|
31
|
-
return ['calls', 'attempts', 'retries', 'inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningTokens'].every(key => typeof row[key] === 'number' && Number.isFinite(row[key]) && Number(row[key]) >= 0)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function validEntry(value: unknown): value is CachedPairScore {
|
|
35
|
-
if (typeof value !== 'object' || value === null) return false
|
|
36
|
-
const row = value as Record<string, unknown>
|
|
37
|
-
return typeof row.scoreA === 'number' && Number.isFinite(row.scoreA) && row.scoreA >= 0 && row.scoreA <= 1 && typeof row.scoreB === 'number' && Number.isFinite(row.scoreB) && row.scoreB >= 0 && row.scoreB <= 1 && validUsage(row.usage) && (row.scoringMode === undefined || row.scoringMode === 'top-logprobs' || row.scoringMode === 'explicit-tag') && typeof row.createdAt === 'number' && Number.isFinite(row.createdAt) && row.createdAt >= 0
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Channel-independent single-flight: concurrent identical tasks share one promise; joiners are flagged so callers can avoid double-counting usage. */
|
|
41
|
-
export class SingleFlight<T> {
|
|
42
|
-
private readonly flights = new Map<string, Promise<T>>()
|
|
43
|
-
async run(key: string, task: () => Promise<T>): Promise<{ value: T; joined: boolean }> {
|
|
44
|
-
const existing = this.flights.get(key)
|
|
45
|
-
if (existing !== undefined) return { value: await existing, joined: true }
|
|
46
|
-
// task() starts synchronously so the flight is registered before its first await.
|
|
47
|
-
const flight = task().finally(() => { if (this.flights.get(key) === flight) this.flights.delete(key) })
|
|
48
|
-
this.flights.set(key, flight)
|
|
49
|
-
return { value: await flight, joined: false }
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export class ScoreCache {
|
|
54
|
-
private readonly file: string
|
|
55
|
-
private readonly maxEntries: number
|
|
56
|
-
private loaded = false
|
|
57
|
-
private entries = new Map<string, CachedPairScore>()
|
|
58
|
-
private readonly inflight = new Map<string, Promise<{ value: CachedPairScore; key: string }>>()
|
|
59
|
-
private writing: Promise<void> = Promise.resolve()
|
|
60
|
-
|
|
61
|
-
constructor(file: string, maxEntries: number) {
|
|
62
|
-
this.file = file
|
|
63
|
-
this.maxEntries = maxEntries
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async load(): Promise<void> {
|
|
67
|
-
if (this.loaded) return
|
|
68
|
-
this.loaded = true
|
|
69
|
-
try {
|
|
70
|
-
const document = JSON.parse(await readFile(this.file, 'utf8')) as CacheDocument
|
|
71
|
-
if (document.version !== 1 || typeof document.entries !== 'object' || document.entries === null) return
|
|
72
|
-
this.entries = new Map(Object.entries(document.entries).filter((entry): entry is [string, CachedPairScore] => validEntry(entry[1])).map(([key, value]) => [key, { ...value, scoringMode: value.scoringMode ?? 'explicit-tag' }]))
|
|
73
|
-
} catch (error) {
|
|
74
|
-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async getOrCreate(key: string, create: () => Promise<CachedPairScore>, keyFor: (value: CachedPairScore) => string = () => key): Promise<{ value: CachedPairScore; hit: boolean }> {
|
|
79
|
-
await this.load()
|
|
80
|
-
const cached = this.entries.get(key)
|
|
81
|
-
if (cached !== undefined) return { value: cached, hit: true }
|
|
82
|
-
const existing = this.inflight.get(key)
|
|
83
|
-
if (existing !== undefined) return { value: (await existing).value, hit: true }
|
|
84
|
-
// The storage key may differ from the lookup key when the actual scoring mode
|
|
85
|
-
// differs from the predicted one (first-call logprobs downgrade).
|
|
86
|
-
const pending = create().then(value => ({ value, key: keyFor(value) }))
|
|
87
|
-
this.inflight.set(key, pending)
|
|
88
|
-
try {
|
|
89
|
-
const landed = await pending
|
|
90
|
-
this.entries.set(landed.key, landed.value)
|
|
91
|
-
this.trim()
|
|
92
|
-
await this.persist()
|
|
93
|
-
return { value: landed.value, hit: false }
|
|
94
|
-
} finally {
|
|
95
|
-
this.inflight.delete(key)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
private trim(): void {
|
|
100
|
-
if (this.entries.size <= this.maxEntries) return
|
|
101
|
-
const sorted = [...this.entries].sort((a, b) => a[1].createdAt - b[1].createdAt)
|
|
102
|
-
for (let index = 0; index < sorted.length - this.maxEntries; index += 1) this.entries.delete(sorted[index]![0])
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
private async persist(): Promise<void> {
|
|
106
|
-
const snapshot: CacheDocument = { version: 1, entries: Object.fromEntries(this.entries) }
|
|
107
|
-
this.writing = this.writing.catch(() => {}).then(async () => {
|
|
108
|
-
await mkdir(dirname(this.file), { recursive: true })
|
|
109
|
-
const temporary = this.file + '.tmp-' + process.pid
|
|
110
|
-
await writeFile(temporary, JSON.stringify(snapshot), 'utf8')
|
|
111
|
-
try { await rename(temporary, this.file) } catch (error) { await unlink(temporary).catch(() => {}); throw error }
|
|
112
|
-
})
|
|
113
|
-
await this.writing
|
|
114
|
-
}
|
|
115
|
-
}
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
4
|
+
import type { UsageStats } from './caller.ts'
|
|
5
|
+
|
|
6
|
+
export interface CachedPairScore {
|
|
7
|
+
scoreA: number
|
|
8
|
+
scoreB: number
|
|
9
|
+
usage: UsageStats
|
|
10
|
+
scoringMode: 'top-logprobs' | 'explicit-tag'
|
|
11
|
+
createdAt: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CacheDocument {
|
|
15
|
+
version: 1
|
|
16
|
+
entries: Record<string, CachedPairScore>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function stableHash(value: unknown): string {
|
|
20
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveCacheFile(cacheDir: string, cwd = process.cwd()): string {
|
|
24
|
+
const root = isAbsolute(cacheDir) ? cacheDir : resolve(cwd, cacheDir)
|
|
25
|
+
return join(root, 'scores-v1.json')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validUsage(value: unknown): value is UsageStats {
|
|
29
|
+
if (typeof value !== 'object' || value === null) return false
|
|
30
|
+
const row = value as Record<string, unknown>
|
|
31
|
+
return ['calls', 'attempts', 'retries', 'inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningTokens'].every(key => typeof row[key] === 'number' && Number.isFinite(row[key]) && Number(row[key]) >= 0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validEntry(value: unknown): value is CachedPairScore {
|
|
35
|
+
if (typeof value !== 'object' || value === null) return false
|
|
36
|
+
const row = value as Record<string, unknown>
|
|
37
|
+
return typeof row.scoreA === 'number' && Number.isFinite(row.scoreA) && row.scoreA >= 0 && row.scoreA <= 1 && typeof row.scoreB === 'number' && Number.isFinite(row.scoreB) && row.scoreB >= 0 && row.scoreB <= 1 && validUsage(row.usage) && (row.scoringMode === undefined || row.scoringMode === 'top-logprobs' || row.scoringMode === 'explicit-tag') && typeof row.createdAt === 'number' && Number.isFinite(row.createdAt) && row.createdAt >= 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Channel-independent single-flight: concurrent identical tasks share one promise; joiners are flagged so callers can avoid double-counting usage. */
|
|
41
|
+
export class SingleFlight<T> {
|
|
42
|
+
private readonly flights = new Map<string, Promise<T>>()
|
|
43
|
+
async run(key: string, task: () => Promise<T>): Promise<{ value: T; joined: boolean }> {
|
|
44
|
+
const existing = this.flights.get(key)
|
|
45
|
+
if (existing !== undefined) return { value: await existing, joined: true }
|
|
46
|
+
// task() starts synchronously so the flight is registered before its first await.
|
|
47
|
+
const flight = task().finally(() => { if (this.flights.get(key) === flight) this.flights.delete(key) })
|
|
48
|
+
this.flights.set(key, flight)
|
|
49
|
+
return { value: await flight, joined: false }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class ScoreCache {
|
|
54
|
+
private readonly file: string
|
|
55
|
+
private readonly maxEntries: number
|
|
56
|
+
private loaded = false
|
|
57
|
+
private entries = new Map<string, CachedPairScore>()
|
|
58
|
+
private readonly inflight = new Map<string, Promise<{ value: CachedPairScore; key: string }>>()
|
|
59
|
+
private writing: Promise<void> = Promise.resolve()
|
|
60
|
+
|
|
61
|
+
constructor(file: string, maxEntries: number) {
|
|
62
|
+
this.file = file
|
|
63
|
+
this.maxEntries = maxEntries
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async load(): Promise<void> {
|
|
67
|
+
if (this.loaded) return
|
|
68
|
+
this.loaded = true
|
|
69
|
+
try {
|
|
70
|
+
const document = JSON.parse(await readFile(this.file, 'utf8')) as CacheDocument
|
|
71
|
+
if (document.version !== 1 || typeof document.entries !== 'object' || document.entries === null) return
|
|
72
|
+
this.entries = new Map(Object.entries(document.entries).filter((entry): entry is [string, CachedPairScore] => validEntry(entry[1])).map(([key, value]) => [key, { ...value, scoringMode: value.scoringMode ?? 'explicit-tag' }]))
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async getOrCreate(key: string, create: () => Promise<CachedPairScore>, keyFor: (value: CachedPairScore) => string = () => key): Promise<{ value: CachedPairScore; hit: boolean }> {
|
|
79
|
+
await this.load()
|
|
80
|
+
const cached = this.entries.get(key)
|
|
81
|
+
if (cached !== undefined) return { value: cached, hit: true }
|
|
82
|
+
const existing = this.inflight.get(key)
|
|
83
|
+
if (existing !== undefined) return { value: (await existing).value, hit: true }
|
|
84
|
+
// The storage key may differ from the lookup key when the actual scoring mode
|
|
85
|
+
// differs from the predicted one (first-call logprobs downgrade).
|
|
86
|
+
const pending = create().then(value => ({ value, key: keyFor(value) }))
|
|
87
|
+
this.inflight.set(key, pending)
|
|
88
|
+
try {
|
|
89
|
+
const landed = await pending
|
|
90
|
+
this.entries.set(landed.key, landed.value)
|
|
91
|
+
this.trim()
|
|
92
|
+
await this.persist()
|
|
93
|
+
return { value: landed.value, hit: false }
|
|
94
|
+
} finally {
|
|
95
|
+
this.inflight.delete(key)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private trim(): void {
|
|
100
|
+
if (this.entries.size <= this.maxEntries) return
|
|
101
|
+
const sorted = [...this.entries].sort((a, b) => a[1].createdAt - b[1].createdAt)
|
|
102
|
+
for (let index = 0; index < sorted.length - this.maxEntries; index += 1) this.entries.delete(sorted[index]![0])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async persist(): Promise<void> {
|
|
106
|
+
const snapshot: CacheDocument = { version: 1, entries: Object.fromEntries(this.entries) }
|
|
107
|
+
this.writing = this.writing.catch(() => {}).then(async () => {
|
|
108
|
+
await mkdir(dirname(this.file), { recursive: true })
|
|
109
|
+
const temporary = this.file + '.tmp-' + process.pid
|
|
110
|
+
await writeFile(temporary, JSON.stringify(snapshot), 'utf8')
|
|
111
|
+
try { await rename(temporary, this.file) } catch (error) { await unlink(temporary).catch(() => {}); throw error }
|
|
112
|
+
})
|
|
113
|
+
await this.writing
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/caller.test.ts
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { callVerifier } from './caller.ts'
|
|
3
|
-
import { TopLogprobCapabilityCache } from './top-logprobs.ts'
|
|
4
|
-
|
|
5
|
-
function chunks(text = '<score_A> A </score_A>') { return [{ type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text }, { type: 'block-end', index: 0, block: { type: 'text', text } }, { type: 'usage', usage: { inputTokens: 7, cacheReadTokens: 3, outputTokens: 4, reasoningTokens: 2 } }, { type: 'finish', reason: { kind: 'stop' } }] as any[] }
|
|
6
|
-
function ctx(settingsValue?: unknown) { return { get(name: string) { if (name === 'settings' && settingsValue !== undefined) return { get: () => settingsValue }; if (name === 'credentials') return { resolve: async () => ({ value: 'secret' }) }; return undefined } } as any }
|
|
7
|
-
function config(stream: (options: any) => AsyncIterable<any>, saveImage = vi.fn(), context = ctx()) { return { ctx: context, llm: { stream } as any, attachments: { saveImage } as any, topLogprobCapabilities: new TopLogprobCapabilityCache(), provider: 'openai', model: 'gpt-5', reasoningEffort: 'high', maxTokens: 100, timeoutMs: 1000, maxRetries: 2, retryBaseDelayMs: 1 } }
|
|
8
|
-
async function* streamOf(items: any[]) { for (const item of items) yield item }
|
|
9
|
-
afterEach(() => vi.unstubAllGlobals())
|
|
10
|
-
|
|
11
|
-
describe('automatic verifier scoring', () => {
|
|
12
|
-
it('falls back to explicit A-T tags when the route has no safe logprob transport', async () => {
|
|
13
|
-
let seen: any
|
|
14
|
-
const result = await callVerifier(config(async function* (options) { seen = options; yield* streamOf(chunks()) }), 'prompt')
|
|
15
|
-
expect(seen.provider).toBe('openai'); expect(result.scoringMode).toBe('explicit-tag')
|
|
16
|
-
})
|
|
17
|
-
it('uses top-logprob distributions on an explicit OpenAI-compatible route', async () => {
|
|
18
|
-
const body = { choices: [{ message: { content: '<score_A> A </score_A>' }, logprobs: { content: [{ token: '<score_A>', logprob: 0, top_logprobs: [] }, { token: 'A', logprob: -0.1, top_logprobs: [{ token: 'A', logprob: Math.log(0.7) }, { token: 'T', logprob: Math.log(0.3) }] }] } }], usage: { prompt_tokens: 10, completion_tokens: 2 } }
|
|
19
|
-
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })))
|
|
20
|
-
let streamed = false
|
|
21
|
-
const context = ctx({ providers: { openai: { api: 'openai-completions', baseURL: 'https://example.test/v1', apiKeyEnv: 'OPENAI_API_KEY' } } })
|
|
22
|
-
const result = await callVerifier(config(async function* () { streamed = true; yield* streamOf(chunks()) }, vi.fn(), context), 'prompt')
|
|
23
|
-
expect(result.scoringMode).toBe('top-logprobs'); expect(result.positions[1]?.length).toBe(2); expect(streamed).toBe(false)
|
|
24
|
-
})
|
|
25
|
-
it('remembers a provider logprob rejection and falls back through DSH', async () => {
|
|
26
|
-
const fetcher = vi.fn(async () => new Response('{"error":{"message":"logprobs unsupported"}}', { status: 400 }))
|
|
27
|
-
vi.stubGlobal('fetch', fetcher)
|
|
28
|
-
const context = ctx({ providers: { openai: { api: 'openai-completions', baseURL: 'https://example.test/v1' } } })
|
|
29
|
-
const cfg = config(async function* () { yield* streamOf(chunks()) }, vi.fn(), context)
|
|
30
|
-
expect((await callVerifier(cfg, 'prompt')).scoringMode).toBe('explicit-tag')
|
|
31
|
-
expect((await callVerifier(cfg, 'prompt2')).scoringMode).toBe('explicit-tag')
|
|
32
|
-
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
33
|
-
})
|
|
34
|
-
})
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { callVerifier } from './caller.ts'
|
|
3
|
+
import { TopLogprobCapabilityCache } from './top-logprobs.ts'
|
|
4
|
+
|
|
5
|
+
function chunks(text = '<score_A> A </score_A>') { return [{ type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text }, { type: 'block-end', index: 0, block: { type: 'text', text } }, { type: 'usage', usage: { inputTokens: 7, cacheReadTokens: 3, outputTokens: 4, reasoningTokens: 2 } }, { type: 'finish', reason: { kind: 'stop' } }] as any[] }
|
|
6
|
+
function ctx(settingsValue?: unknown) { return { get(name: string) { if (name === 'settings' && settingsValue !== undefined) return { get: () => settingsValue }; if (name === 'credentials') return { resolve: async () => ({ value: 'secret' }) }; return undefined } } as any }
|
|
7
|
+
function config(stream: (options: any) => AsyncIterable<any>, saveImage = vi.fn(), context = ctx()) { return { ctx: context, llm: { stream } as any, attachments: { saveImage } as any, topLogprobCapabilities: new TopLogprobCapabilityCache(), provider: 'openai', model: 'gpt-5', reasoningEffort: 'high', maxTokens: 100, timeoutMs: 1000, maxRetries: 2, retryBaseDelayMs: 1 } }
|
|
8
|
+
async function* streamOf(items: any[]) { for (const item of items) yield item }
|
|
9
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
10
|
+
|
|
11
|
+
describe('automatic verifier scoring', () => {
|
|
12
|
+
it('falls back to explicit A-T tags when the route has no safe logprob transport', async () => {
|
|
13
|
+
let seen: any
|
|
14
|
+
const result = await callVerifier(config(async function* (options) { seen = options; yield* streamOf(chunks()) }), 'prompt')
|
|
15
|
+
expect(seen.provider).toBe('openai'); expect(result.scoringMode).toBe('explicit-tag')
|
|
16
|
+
})
|
|
17
|
+
it('uses top-logprob distributions on an explicit OpenAI-compatible route', async () => {
|
|
18
|
+
const body = { choices: [{ message: { content: '<score_A> A </score_A>' }, logprobs: { content: [{ token: '<score_A>', logprob: 0, top_logprobs: [] }, { token: 'A', logprob: -0.1, top_logprobs: [{ token: 'A', logprob: Math.log(0.7) }, { token: 'T', logprob: Math.log(0.3) }] }] } }], usage: { prompt_tokens: 10, completion_tokens: 2 } }
|
|
19
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })))
|
|
20
|
+
let streamed = false
|
|
21
|
+
const context = ctx({ providers: { openai: { api: 'openai-completions', baseURL: 'https://example.test/v1', apiKeyEnv: 'OPENAI_API_KEY' } } })
|
|
22
|
+
const result = await callVerifier(config(async function* () { streamed = true; yield* streamOf(chunks()) }, vi.fn(), context), 'prompt')
|
|
23
|
+
expect(result.scoringMode).toBe('top-logprobs'); expect(result.positions[1]?.length).toBe(2); expect(streamed).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
it('remembers a provider logprob rejection and falls back through DSH', async () => {
|
|
26
|
+
const fetcher = vi.fn(async () => new Response('{"error":{"message":"logprobs unsupported"}}', { status: 400 }))
|
|
27
|
+
vi.stubGlobal('fetch', fetcher)
|
|
28
|
+
const context = ctx({ providers: { openai: { api: 'openai-completions', baseURL: 'https://example.test/v1' } } })
|
|
29
|
+
const cfg = config(async function* () { yield* streamOf(chunks()) }, vi.fn(), context)
|
|
30
|
+
expect((await callVerifier(cfg, 'prompt')).scoringMode).toBe('explicit-tag')
|
|
31
|
+
expect((await callVerifier(cfg, 'prompt2')).scoringMode).toBe('explicit-tag')
|
|
32
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
33
|
+
})
|
|
34
|
+
})
|
package/src/caller.ts
CHANGED
|
@@ -13,154 +13,154 @@ function deepFreeze<T>(value: T): T {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
return value
|
|
16
|
-
}
|
|
17
|
-
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
|
18
|
-
import type { CompletionLogprobs } from './core.ts'
|
|
19
|
-
import { TopLogprobCapabilityCache, TopLogprobsUnsupportedError, callTopLogprobs, resolveTopLogprobRoute } from './top-logprobs.ts'
|
|
20
|
-
import type { Context } from '@deepseek-ai/cordis'
|
|
21
|
-
|
|
22
|
-
export interface VerifierClientConfig {
|
|
23
|
-
ctx: Context
|
|
24
|
-
llm: LlmRuntime
|
|
25
|
-
attachments: AttachmentStore
|
|
26
|
-
topLogprobCapabilities: TopLogprobCapabilityCache
|
|
27
|
-
provider: string
|
|
28
|
-
model: string
|
|
29
|
-
reasoningEffort?: string
|
|
30
|
-
maxTokens: number
|
|
31
|
-
timeoutMs: number
|
|
32
|
-
maxRetries: number
|
|
33
|
-
retryBaseDelayMs: number
|
|
34
|
-
limiter?: RequestLimiter
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface VerifierImage {
|
|
38
|
-
data: Uint8Array
|
|
39
|
-
mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface UsageStats {
|
|
43
|
-
calls: number
|
|
44
|
-
attempts: number
|
|
45
|
-
retries: number
|
|
46
|
-
inputTokens: number
|
|
47
|
-
cachedInputTokens: number
|
|
48
|
-
outputTokens: number
|
|
49
|
-
reasoningTokens: number
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export type ScoringMode = 'top-logprobs' | 'explicit-tag'
|
|
53
|
-
export interface VerifierCompletion extends CompletionLogprobs { usage: UsageStats; scoringMode: ScoringMode }
|
|
54
|
-
|
|
55
|
-
function failureMessage(finish: FinishReason): string | undefined {
|
|
56
|
-
if (finish.kind === 'error' || finish.kind === 'aborted') return finish.failure.message
|
|
57
|
-
if (finish.kind === 'max-tokens') return 'verifier response reached max tokens before completing its answer'
|
|
58
|
-
return undefined
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
62
|
-
if (signal?.aborted) throw signal.reason
|
|
63
|
-
await new Promise<void>((resolve, reject) => {
|
|
64
|
-
const timer = setTimeout(resolve, ms)
|
|
65
|
-
const abort = () => { clearTimeout(timer); reject(signal?.reason) }
|
|
66
|
-
signal?.addEventListener('abort', abort, { once: true })
|
|
67
|
-
})
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function usage(attempts: number, value = {} as { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; reasoningTokens?: number }): UsageStats {
|
|
71
|
-
return { calls: 1, attempts, retries: attempts - 1, inputTokens: value.inputTokens ?? 0, cachedInputTokens: (value.cacheReadTokens ?? 0) + (value.cacheWriteTokens ?? 0), outputTokens: value.outputTokens ?? 0, reasoningTokens: value.reasoningTokens ?? 0 }
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function callExplicitTag(config: VerifierClientConfig, prompt: string, signal?: AbortSignal, images?: readonly VerifierImage[]): Promise<VerifierCompletion> {
|
|
75
|
-
let attempt = 0
|
|
76
|
-
while (true) {
|
|
77
|
-
attempt += 1
|
|
78
|
-
const controller = new AbortController()
|
|
79
|
-
const timeout = setTimeout(() => controller.abort(new Error('llm-verifier: request timed out')), config.timeoutMs)
|
|
80
|
-
const abort = () => controller.abort(signal?.reason)
|
|
81
|
-
signal?.addEventListener('abort', abort, { once: true })
|
|
82
|
-
try {
|
|
83
|
-
const content: ContentBlock[] = [{ type: 'text', text: prompt }]
|
|
84
|
-
for (const image of images ?? []) {
|
|
85
|
-
const ref = await config.attachments.saveImage({ data: image.data, mediaType: image.mediaType })
|
|
86
|
-
content.push({ type: 'image', attachment: ref })
|
|
87
|
-
}
|
|
88
|
-
const messages = [createUserMessage({ content, source: { kind: 'plugin', plugin: 'dsh-llm-verifier' } })]
|
|
89
|
-
const assembler = new BlockAssembler()
|
|
90
|
-
const options = deepFreeze({
|
|
91
|
-
provider: config.provider,
|
|
92
|
-
model: config.model,
|
|
93
|
-
...(config.reasoningEffort ? { reasoningEffort: ReasoningEffortId(config.reasoningEffort) } : {}),
|
|
94
|
-
messages,
|
|
95
|
-
maxTokens: config.maxTokens,
|
|
96
|
-
temperature: 1,
|
|
97
|
-
signal: controller.signal,
|
|
98
|
-
})
|
|
99
|
-
for await (const chunk of config.llm.stream(options)) assembler.push(chunk)
|
|
100
|
-
const failed = failureMessage(assembler.finish)
|
|
101
|
-
if (failed !== undefined) throw new Error('llm-verifier: model call failed: ' + failed)
|
|
102
|
-
const text = assembler.blocks().filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text').map(block => block.text).join('')
|
|
103
|
-
if (!text.trim()) throw new Error('llm-verifier: selected DSH model produced no text')
|
|
104
|
-
// DSH adapters expose provider-neutral text/usage but not top-logprob candidates.
|
|
105
|
-
// extractScore() therefore uses the model's explicit final A–T tags.
|
|
106
|
-
return { text, tokens: [], positions: [], scoringMode: 'explicit-tag', usage: usage(attempt, assembler.usage) }
|
|
107
|
-
} catch (error) {
|
|
108
|
-
if (signal?.aborted) throw signal.reason
|
|
109
|
-
if (attempt > config.maxRetries || !(error instanceof Error) || !/rate|quota|timeout|timed out|temporar|network|fetch|socket|5\d\d/i.test(error.message)) throw error
|
|
110
|
-
await delay(Math.min(30000, config.retryBaseDelayMs * 2 ** (attempt - 1) * (0.8 + Math.random() * 0.4)), signal)
|
|
111
|
-
} finally {
|
|
112
|
-
clearTimeout(timeout)
|
|
113
|
-
signal?.removeEventListener('abort', abort)
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export class RequestLimiter {
|
|
119
|
-
private active = 0
|
|
120
|
-
private readonly queue: Array<() => void> = []
|
|
121
|
-
constructor(readonly limit: number) { if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('llm-verifier: request concurrency limit must be a positive integer') }
|
|
122
|
-
async run<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
123
|
-
if (this.active >= this.limit) await new Promise<void>((resolve, reject) => {
|
|
124
|
-
const enter = () => { signal?.removeEventListener('abort', abort); resolve() }
|
|
125
|
-
const abort = () => { const index = this.queue.indexOf(enter); if (index >= 0) this.queue.splice(index, 1); reject(signal?.reason) }
|
|
126
|
-
this.queue.push(enter); signal?.addEventListener('abort', abort, { once: true })
|
|
127
|
-
})
|
|
128
|
-
if (signal?.aborted) throw signal.reason
|
|
129
|
-
this.active += 1
|
|
130
|
-
try { return await operation() } finally { this.active -= 1; this.queue.shift()?.() }
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Best-effort pre-call channel prediction for cache identity only; callAutomatic() stays the runtime source of truth. */
|
|
135
|
-
export async function predictScoringChannel(config: VerifierClientConfig): Promise<ScoringMode> {
|
|
136
|
-
await config.topLogprobCapabilities.ensureLoaded()
|
|
137
|
-
return config.topLogprobCapabilities.isUnsupported(config.provider, config.model) ? 'explicit-tag' : 'top-logprobs'
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function callAutomatic(config: VerifierClientConfig, prompt: string, signal?: AbortSignal, images?: readonly VerifierImage[]): Promise<VerifierCompletion> {
|
|
141
|
-
await config.topLogprobCapabilities.ensureLoaded()
|
|
142
|
-
if (!config.topLogprobCapabilities.isUnsupported(config.provider, config.model)) {
|
|
143
|
-
const route = await resolveTopLogprobRoute(config.ctx, config.provider)
|
|
144
|
-
if (route !== undefined) {
|
|
145
|
-
try { return await callTopLogprobs(route, config.model, prompt, config.maxTokens, config.reasoningEffort, signal, images) }
|
|
146
|
-
catch (error) {
|
|
147
|
-
if (!(error instanceof TopLogprobsUnsupportedError)) throw error
|
|
148
|
-
config.topLogprobCapabilities.markUnsupported(config.provider, config.model)
|
|
149
|
-
}
|
|
150
|
-
} else config.topLogprobCapabilities.markUnsupported(config.provider, config.model)
|
|
151
|
-
}
|
|
152
|
-
return callExplicitTag(config, prompt, signal, images)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export async function callVerifier(config: VerifierClientConfig, prompt: string, signal?: AbortSignal, images?: readonly VerifierImage[]): Promise<VerifierCompletion> {
|
|
156
|
-
const invoke = () => callAutomatic(config, prompt, signal, images)
|
|
157
|
-
return config.limiter === undefined ? invoke() : config.limiter.run(invoke, signal)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Plain-text verifier call for conservative JSON routing; probability labels are intentionally bypassed. */
|
|
161
|
-
export async function callVerifierText(config: VerifierClientConfig, prompt: string, signal?: AbortSignal): Promise<VerifierCompletion> {
|
|
162
|
-
const invoke = () => callExplicitTag(config, prompt, signal)
|
|
163
|
-
return config.limiter === undefined ? invoke() : config.limiter.run(invoke, signal)
|
|
164
|
-
}
|
|
165
|
-
export function addUsage(target: UsageStats, source: UsageStats): void { for (const key of ['calls', 'attempts', 'retries', 'inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningTokens'] as const) target[key] += source[key] }
|
|
166
|
-
export function emptyUsage(): UsageStats { return { calls: 0, attempts: 0, retries: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0 } }
|
|
16
|
+
}
|
|
17
|
+
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
|
18
|
+
import type { CompletionLogprobs } from './core.ts'
|
|
19
|
+
import { TopLogprobCapabilityCache, TopLogprobsUnsupportedError, callTopLogprobs, resolveTopLogprobRoute } from './top-logprobs.ts'
|
|
20
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
21
|
+
|
|
22
|
+
export interface VerifierClientConfig {
|
|
23
|
+
ctx: Context
|
|
24
|
+
llm: LlmRuntime
|
|
25
|
+
attachments: AttachmentStore
|
|
26
|
+
topLogprobCapabilities: TopLogprobCapabilityCache
|
|
27
|
+
provider: string
|
|
28
|
+
model: string
|
|
29
|
+
reasoningEffort?: string
|
|
30
|
+
maxTokens: number
|
|
31
|
+
timeoutMs: number
|
|
32
|
+
maxRetries: number
|
|
33
|
+
retryBaseDelayMs: number
|
|
34
|
+
limiter?: RequestLimiter
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface VerifierImage {
|
|
38
|
+
data: Uint8Array
|
|
39
|
+
mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UsageStats {
|
|
43
|
+
calls: number
|
|
44
|
+
attempts: number
|
|
45
|
+
retries: number
|
|
46
|
+
inputTokens: number
|
|
47
|
+
cachedInputTokens: number
|
|
48
|
+
outputTokens: number
|
|
49
|
+
reasoningTokens: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ScoringMode = 'top-logprobs' | 'explicit-tag'
|
|
53
|
+
export interface VerifierCompletion extends CompletionLogprobs { usage: UsageStats; scoringMode: ScoringMode }
|
|
54
|
+
|
|
55
|
+
function failureMessage(finish: FinishReason): string | undefined {
|
|
56
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') return finish.failure.message
|
|
57
|
+
if (finish.kind === 'max-tokens') return 'verifier response reached max tokens before completing its answer'
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
62
|
+
if (signal?.aborted) throw signal.reason
|
|
63
|
+
await new Promise<void>((resolve, reject) => {
|
|
64
|
+
const timer = setTimeout(resolve, ms)
|
|
65
|
+
const abort = () => { clearTimeout(timer); reject(signal?.reason) }
|
|
66
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function usage(attempts: number, value = {} as { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; reasoningTokens?: number }): UsageStats {
|
|
71
|
+
return { calls: 1, attempts, retries: attempts - 1, inputTokens: value.inputTokens ?? 0, cachedInputTokens: (value.cacheReadTokens ?? 0) + (value.cacheWriteTokens ?? 0), outputTokens: value.outputTokens ?? 0, reasoningTokens: value.reasoningTokens ?? 0 }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function callExplicitTag(config: VerifierClientConfig, prompt: string, signal?: AbortSignal, images?: readonly VerifierImage[]): Promise<VerifierCompletion> {
|
|
75
|
+
let attempt = 0
|
|
76
|
+
while (true) {
|
|
77
|
+
attempt += 1
|
|
78
|
+
const controller = new AbortController()
|
|
79
|
+
const timeout = setTimeout(() => controller.abort(new Error('llm-verifier: request timed out')), config.timeoutMs)
|
|
80
|
+
const abort = () => controller.abort(signal?.reason)
|
|
81
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
82
|
+
try {
|
|
83
|
+
const content: ContentBlock[] = [{ type: 'text', text: prompt }]
|
|
84
|
+
for (const image of images ?? []) {
|
|
85
|
+
const ref = await config.attachments.saveImage({ data: image.data, mediaType: image.mediaType })
|
|
86
|
+
content.push({ type: 'image', attachment: ref })
|
|
87
|
+
}
|
|
88
|
+
const messages = [createUserMessage({ content, source: { kind: 'plugin', plugin: 'dsh-llm-verifier' } })]
|
|
89
|
+
const assembler = new BlockAssembler()
|
|
90
|
+
const options = deepFreeze({
|
|
91
|
+
provider: config.provider,
|
|
92
|
+
model: config.model,
|
|
93
|
+
...(config.reasoningEffort ? { reasoningEffort: ReasoningEffortId(config.reasoningEffort) } : {}),
|
|
94
|
+
messages,
|
|
95
|
+
maxTokens: config.maxTokens,
|
|
96
|
+
temperature: 1,
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
})
|
|
99
|
+
for await (const chunk of config.llm.stream(options)) assembler.push(chunk)
|
|
100
|
+
const failed = failureMessage(assembler.finish)
|
|
101
|
+
if (failed !== undefined) throw new Error('llm-verifier: model call failed: ' + failed)
|
|
102
|
+
const text = assembler.blocks().filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text').map(block => block.text).join('')
|
|
103
|
+
if (!text.trim()) throw new Error('llm-verifier: selected DSH model produced no text')
|
|
104
|
+
// DSH adapters expose provider-neutral text/usage but not top-logprob candidates.
|
|
105
|
+
// extractScore() therefore uses the model's explicit final A–T tags.
|
|
106
|
+
return { text, tokens: [], positions: [], scoringMode: 'explicit-tag', usage: usage(attempt, assembler.usage) }
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (signal?.aborted) throw signal.reason
|
|
109
|
+
if (attempt > config.maxRetries || !(error instanceof Error) || !/rate|quota|timeout|timed out|temporar|network|fetch|socket|5\d\d/i.test(error.message)) throw error
|
|
110
|
+
await delay(Math.min(30000, config.retryBaseDelayMs * 2 ** (attempt - 1) * (0.8 + Math.random() * 0.4)), signal)
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timeout)
|
|
113
|
+
signal?.removeEventListener('abort', abort)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class RequestLimiter {
|
|
119
|
+
private active = 0
|
|
120
|
+
private readonly queue: Array<() => void> = []
|
|
121
|
+
constructor(readonly limit: number) { if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('llm-verifier: request concurrency limit must be a positive integer') }
|
|
122
|
+
async run<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
123
|
+
if (this.active >= this.limit) await new Promise<void>((resolve, reject) => {
|
|
124
|
+
const enter = () => { signal?.removeEventListener('abort', abort); resolve() }
|
|
125
|
+
const abort = () => { const index = this.queue.indexOf(enter); if (index >= 0) this.queue.splice(index, 1); reject(signal?.reason) }
|
|
126
|
+
this.queue.push(enter); signal?.addEventListener('abort', abort, { once: true })
|
|
127
|
+
})
|
|
128
|
+
if (signal?.aborted) throw signal.reason
|
|
129
|
+
this.active += 1
|
|
130
|
+
try { return await operation() } finally { this.active -= 1; this.queue.shift()?.() }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Best-effort pre-call channel prediction for cache identity only; callAutomatic() stays the runtime source of truth. */
|
|
135
|
+
export async function predictScoringChannel(config: VerifierClientConfig): Promise<ScoringMode> {
|
|
136
|
+
await config.topLogprobCapabilities.ensureLoaded()
|
|
137
|
+
return config.topLogprobCapabilities.isUnsupported(config.provider, config.model) ? 'explicit-tag' : 'top-logprobs'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function callAutomatic(config: VerifierClientConfig, prompt: string, signal?: AbortSignal, images?: readonly VerifierImage[]): Promise<VerifierCompletion> {
|
|
141
|
+
await config.topLogprobCapabilities.ensureLoaded()
|
|
142
|
+
if (!config.topLogprobCapabilities.isUnsupported(config.provider, config.model)) {
|
|
143
|
+
const route = await resolveTopLogprobRoute(config.ctx, config.provider)
|
|
144
|
+
if (route !== undefined) {
|
|
145
|
+
try { return await callTopLogprobs(route, config.model, prompt, config.maxTokens, config.reasoningEffort, signal, images) }
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (!(error instanceof TopLogprobsUnsupportedError)) throw error
|
|
148
|
+
config.topLogprobCapabilities.markUnsupported(config.provider, config.model)
|
|
149
|
+
}
|
|
150
|
+
} else config.topLogprobCapabilities.markUnsupported(config.provider, config.model)
|
|
151
|
+
}
|
|
152
|
+
return callExplicitTag(config, prompt, signal, images)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function callVerifier(config: VerifierClientConfig, prompt: string, signal?: AbortSignal, images?: readonly VerifierImage[]): Promise<VerifierCompletion> {
|
|
156
|
+
const invoke = () => callAutomatic(config, prompt, signal, images)
|
|
157
|
+
return config.limiter === undefined ? invoke() : config.limiter.run(invoke, signal)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Plain-text verifier call for conservative JSON routing; probability labels are intentionally bypassed. */
|
|
161
|
+
export async function callVerifierText(config: VerifierClientConfig, prompt: string, signal?: AbortSignal): Promise<VerifierCompletion> {
|
|
162
|
+
const invoke = () => callExplicitTag(config, prompt, signal)
|
|
163
|
+
return config.limiter === undefined ? invoke() : config.limiter.run(invoke, signal)
|
|
164
|
+
}
|
|
165
|
+
export function addUsage(target: UsageStats, source: UsageStats): void { for (const key of ['calls', 'attempts', 'retries', 'inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningTokens'] as const) target[key] += source[key] }
|
|
166
|
+
export function emptyUsage(): UsageStats { return { calls: 0, attempts: 0, retries: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0 } }
|