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.
Files changed (50) hide show
  1. package/LICENSE +22 -22
  2. package/README.md +301 -301
  3. package/cordis.patch.yml +4 -4
  4. package/lib/client.js +176 -14
  5. package/lib/index.js +418 -43
  6. package/lib/types/client-i18n.d.ts +27 -0
  7. package/lib/types/client-i18n.js +18 -0
  8. package/lib/types/client.d.ts +15 -2
  9. package/lib/types/client.js +134 -9
  10. package/lib/types/config.d.ts +4 -0
  11. package/lib/types/config.js +5 -3
  12. package/lib/types/index.d.ts +2 -0
  13. package/lib/types/index.js +185 -4
  14. package/lib/types/plan-gate.d.ts +14 -0
  15. package/lib/types/plan-gate.js +70 -0
  16. package/lib/types/router.d.ts +10 -1
  17. package/lib/types/router.js +37 -7
  18. package/lib/types/session.js +25 -10
  19. package/lib/types/team-gate.d.ts +11 -0
  20. package/lib/types/team-gate.js +51 -0
  21. package/package.json +112 -112
  22. package/src/auto.test.ts +86 -86
  23. package/src/auto.ts +133 -133
  24. package/src/cache.ts +115 -115
  25. package/src/caller.test.ts +34 -34
  26. package/src/caller.ts +151 -151
  27. package/src/client-i18n.ts +18 -0
  28. package/src/client.tsx +385 -213
  29. package/src/config.ts +189 -183
  30. package/src/core.test.ts +71 -71
  31. package/src/core.ts +205 -205
  32. package/src/engine.test.ts +136 -136
  33. package/src/engine.ts +140 -140
  34. package/src/images.ts +33 -33
  35. package/src/index.ts +416 -247
  36. package/src/parity.test.ts +85 -85
  37. package/src/plan-gate.test.ts +56 -0
  38. package/src/plan-gate.ts +77 -0
  39. package/src/router.test.ts +107 -89
  40. package/src/router.ts +349 -309
  41. package/src/session.test.ts +53 -36
  42. package/src/session.ts +117 -103
  43. package/src/statistics.test.ts +80 -80
  44. package/src/statistics.ts +328 -328
  45. package/src/team-gate.test.ts +56 -0
  46. package/src/team-gate.ts +63 -0
  47. package/src/top-logprobs.test.ts +84 -84
  48. package/src/top-logprobs.ts +153 -153
  49. package/src/topic-storage.test.ts +31 -31
  50. 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
+ }
@@ -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 } }