dsh-llm-verifier 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/LICENSE +22 -0
- package/README.md +218 -0
- package/cordis.patch.yml +4 -0
- package/lib/caller-BgqCctCh.js +324 -0
- package/lib/caller-CGlgZ-Su.js +324 -0
- package/lib/caller.js +2 -0
- package/lib/client.js +385 -0
- package/lib/core.js +163 -0
- package/lib/index.js +1031 -0
- package/lib/types/cache.d.ts +27 -0
- package/lib/types/cache.js +82 -0
- package/lib/types/caller.d.ts +48 -0
- package/lib/types/caller.js +125 -0
- package/lib/types/client.d.ts +4 -0
- package/lib/types/client.js +66 -0
- package/lib/types/config.d.ts +35 -0
- package/lib/types/config.js +57 -0
- package/lib/types/core.d.ts +38 -0
- package/lib/types/core.js +177 -0
- package/lib/types/engine.d.ts +79 -0
- package/lib/types/engine.js +146 -0
- package/lib/types/images.d.ts +3 -0
- package/lib/types/images.js +45 -0
- package/lib/types/index.d.ts +11 -0
- package/lib/types/index.js +42 -0
- package/lib/types/session.d.ts +23 -0
- package/lib/types/session.js +67 -0
- package/lib/types/top-logprobs.d.ts +24 -0
- package/lib/types/top-logprobs.js +97 -0
- package/package.json +110 -0
- package/src/cache.ts +88 -0
- package/src/caller.test.ts +34 -0
- package/src/caller.ts +138 -0
- package/src/client.tsx +48 -0
- package/src/config.ts +84 -0
- package/src/core.test.ts +67 -0
- package/src/core.ts +204 -0
- package/src/engine.ts +109 -0
- package/src/images.ts +33 -0
- package/src/index.ts +48 -0
- package/src/parity.test.ts +71 -0
- package/src/session.test.ts +22 -0
- package/src/session.ts +75 -0
- package/src/top-logprobs.ts +97 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
2
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
3
|
+
export class TopLogprobsUnsupportedError extends Error {
|
|
4
|
+
constructor(message) { super(message); this.name = 'TopLogprobsUnsupportedError'; }
|
|
5
|
+
}
|
|
6
|
+
function object(value) { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined; }
|
|
7
|
+
function text(value) { return typeof value === 'string' && value.trim() ? value.trim() : undefined; }
|
|
8
|
+
function endpoint(baseURL) { return baseURL.replace(/\/+$/, '') + '/chat/completions'; }
|
|
9
|
+
function dataUrl(image) { return 'data:' + image.mediaType + ';base64,' + Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength).toString('base64'); }
|
|
10
|
+
async function credential(ctx, name) {
|
|
11
|
+
if (!name)
|
|
12
|
+
return undefined;
|
|
13
|
+
const provider = ctx.get('credentials');
|
|
14
|
+
return (await provider?.resolve(credentialRef(name)))?.value;
|
|
15
|
+
}
|
|
16
|
+
export async function resolveTopLogprobRoute(ctx, provider) {
|
|
17
|
+
const settings = ctx.get('settings');
|
|
18
|
+
if (provider === 'deepseek-official') {
|
|
19
|
+
const value = settings ? object(settings.get(settingsNamespace('llm-deepseek'))) ?? {} : {};
|
|
20
|
+
const apiKeyEnv = text(value.apiKeyEnv) ?? 'DEEPSEEK_API_KEY';
|
|
21
|
+
const apiKey = await credential(ctx, apiKeyEnv);
|
|
22
|
+
if (!apiKey)
|
|
23
|
+
return undefined;
|
|
24
|
+
return { baseURL: text(value.baseURL) ?? 'https://api.deepseek.com', apiKey, deepSeekThinking: true };
|
|
25
|
+
}
|
|
26
|
+
if (!settings)
|
|
27
|
+
return undefined;
|
|
28
|
+
const root = object(settings.get(settingsNamespace('llm-pi-ai')));
|
|
29
|
+
const profiles = object(root?.providers);
|
|
30
|
+
const profile = object(profiles?.[provider]);
|
|
31
|
+
// Only explicitly OpenAI-compatible profiles are safe to serialize directly.
|
|
32
|
+
// Other DSH adapters keep their private protocol and use the explicit-tag fallback.
|
|
33
|
+
if (!profile || profile.api !== 'openai-completions')
|
|
34
|
+
return undefined;
|
|
35
|
+
const baseURL = text(profile.baseURL);
|
|
36
|
+
if (!baseURL || !/^https:\/\//i.test(baseURL))
|
|
37
|
+
return undefined;
|
|
38
|
+
const apiKey = await credential(ctx, text(profile.apiKeyEnv));
|
|
39
|
+
const rawHeaders = object(profile.headers);
|
|
40
|
+
const headers = rawHeaders === undefined ? undefined : Object.fromEntries(Object.entries(rawHeaders).filter((entry) => typeof entry[1] === 'string'));
|
|
41
|
+
return { baseURL, ...(apiKey ? { apiKey } : {}), ...(headers ? { headers } : {}), deepSeekThinking: false };
|
|
42
|
+
}
|
|
43
|
+
export async function callTopLogprobs(route, model, prompt, maxTokens, reasoningEffort, signal, images) {
|
|
44
|
+
const content = images?.length ? [{ type: 'text', text: prompt }, ...images.map(image => ({ type: 'image_url', image_url: { url: dataUrl(image) } }))] : prompt;
|
|
45
|
+
const thinking = route.deepSeekThinking && reasoningEffort ? reasoningEffort === 'off' ? { thinking: { type: 'disabled' } } : { thinking: { type: 'enabled' }, reasoning_effort: reasoningEffort } : {};
|
|
46
|
+
const response = await fetch(endpoint(route.baseURL), {
|
|
47
|
+
method: 'POST', redirect: 'error', signal,
|
|
48
|
+
headers: { 'content-type': 'application/json', ...(route.apiKey ? { authorization: 'Bearer ' + route.apiKey } : {}), ...route.headers },
|
|
49
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content }], max_tokens: maxTokens, temperature: 1, logprobs: true, top_logprobs: 20, ...thinking }),
|
|
50
|
+
});
|
|
51
|
+
const raw = await response.text();
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
const excerpt = raw.slice(0, 1000);
|
|
54
|
+
if ([400, 404, 405, 415, 422].includes(response.status) && /logprob|top_logprobs|unsupported|unknown (?:field|parameter)|unrecognized (?:field|parameter)|not support/i.test(excerpt))
|
|
55
|
+
throw new TopLogprobsUnsupportedError('provider rejected top_logprobs: HTTP ' + response.status + ' ' + excerpt);
|
|
56
|
+
throw new Error('llm-verifier: top_logprobs request failed with HTTP ' + response.status + ': ' + excerpt);
|
|
57
|
+
}
|
|
58
|
+
let body;
|
|
59
|
+
try {
|
|
60
|
+
body = object(JSON.parse(raw)) ?? {};
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new Error('llm-verifier: top_logprobs endpoint returned invalid JSON');
|
|
64
|
+
}
|
|
65
|
+
const choices = Array.isArray(body.choices) ? body.choices : [];
|
|
66
|
+
const choice = object(choices[0]);
|
|
67
|
+
const message = object(choice?.message);
|
|
68
|
+
const answer = typeof message?.content === 'string' ? message.content : '';
|
|
69
|
+
const logprobs = object(choice?.logprobs);
|
|
70
|
+
const rows = Array.isArray(logprobs?.content) ? logprobs.content : [];
|
|
71
|
+
if (!rows.length)
|
|
72
|
+
throw new TopLogprobsUnsupportedError('provider returned no token logprobs');
|
|
73
|
+
const tokens = [];
|
|
74
|
+
const positions = [];
|
|
75
|
+
for (const rawRow of rows) {
|
|
76
|
+
const row = object(rawRow) ?? {};
|
|
77
|
+
const token = typeof row.token === 'string' ? row.token : '';
|
|
78
|
+
tokens.push(token);
|
|
79
|
+
const top = Array.isArray(row.top_logprobs) ? row.top_logprobs : [];
|
|
80
|
+
const alternatives = top.flatMap(value => { const item = object(value); return item && typeof item.token === 'string' && typeof item.logprob === 'number' ? [{ token: item.token, logprob: item.logprob }] : []; });
|
|
81
|
+
if (!alternatives.length && typeof row.logprob === 'number')
|
|
82
|
+
alternatives.push({ token, logprob: row.logprob });
|
|
83
|
+
positions.push(alternatives);
|
|
84
|
+
}
|
|
85
|
+
const rawUsage = object(body.usage) ?? {};
|
|
86
|
+
const promptDetails = object(rawUsage.prompt_tokens_details) ?? {};
|
|
87
|
+
const completionDetails = object(rawUsage.completion_tokens_details) ?? {};
|
|
88
|
+
const cached = Number(rawUsage.prompt_cache_hit_tokens ?? promptDetails.cached_tokens ?? 0) || 0;
|
|
89
|
+
const input = Number(rawUsage.prompt_tokens ?? 0) || 0;
|
|
90
|
+
return { text: answer, tokens, positions, scoringMode: 'top-logprobs', usage: { calls: 1, attempts: 1, retries: 0, inputTokens: Math.max(0, input - cached), cachedInputTokens: cached, outputTokens: Number(rawUsage.completion_tokens ?? 0) || 0, reasoningTokens: Number(completionDetails.reasoning_tokens ?? 0) || 0 } };
|
|
91
|
+
}
|
|
92
|
+
export class TopLogprobCapabilityCache {
|
|
93
|
+
unsupported = new Set();
|
|
94
|
+
isUnsupported(provider, model) { return this.unsupported.has(provider + '\0' + model); }
|
|
95
|
+
markUnsupported(provider, model) { this.unsupported.add(provider + '\0' + model); }
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=top-logprobs.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-llm-verifier",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Configurable DSH-native LLM verifier with a Web settings page",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/Aa728848/dsh-llm-verifier.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/Aa728848/dsh-llm-verifier#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/Aa728848/dsh-llm-verifier/issues"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"deepseek",
|
|
15
|
+
"dsh",
|
|
16
|
+
"verifier",
|
|
17
|
+
"llm",
|
|
18
|
+
"cordis",
|
|
19
|
+
"plugin"
|
|
20
|
+
],
|
|
21
|
+
"author": "eddyskywalker",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "lib/index.js",
|
|
24
|
+
"types": "lib/types/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./lib/types/index.d.ts",
|
|
28
|
+
"default": "./lib/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./core": {
|
|
31
|
+
"types": "./lib/types/core.d.ts",
|
|
32
|
+
"default": "./lib/core.js"
|
|
33
|
+
},
|
|
34
|
+
"./caller": {
|
|
35
|
+
"types": "./lib/types/caller.d.ts",
|
|
36
|
+
"default": "./lib/caller.js"
|
|
37
|
+
},
|
|
38
|
+
"./package.json": "./package.json",
|
|
39
|
+
"./client": {
|
|
40
|
+
"types": "./lib/types/client.d.ts",
|
|
41
|
+
"default": "./lib/client.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"dsh": {
|
|
45
|
+
"bundle": {
|
|
46
|
+
"patch": "./cordis.patch.yml"
|
|
47
|
+
},
|
|
48
|
+
"client": {
|
|
49
|
+
"inject": [
|
|
50
|
+
"@deepseek-ai/dsh-client-connection",
|
|
51
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
52
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
54
|
+
"@deepseek-ai/dsh-client-locale",
|
|
55
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
56
|
+
],
|
|
57
|
+
"platform": "web"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
62
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
|
|
63
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.7",
|
|
64
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.7",
|
|
65
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
|
|
66
|
+
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.7",
|
|
67
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
68
|
+
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.7"
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"schemastery": "^3.18.0"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
75
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
|
|
76
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.7",
|
|
77
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.7",
|
|
78
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
|
|
79
|
+
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.7",
|
|
80
|
+
"@types/node": "^22.20.0",
|
|
81
|
+
"tsdown": "0.22.2",
|
|
82
|
+
"typescript": "~5.7.2",
|
|
83
|
+
"vitest": "^3.0.0",
|
|
84
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
85
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.7",
|
|
86
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.7",
|
|
87
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
|
|
88
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
|
|
89
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
|
|
90
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.7",
|
|
91
|
+
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.7",
|
|
92
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
|
|
93
|
+
"react": "^18.2.0",
|
|
94
|
+
"@types/react": "~18.3.1",
|
|
95
|
+
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.7"
|
|
96
|
+
},
|
|
97
|
+
"files": [
|
|
98
|
+
"lib/**/*.js",
|
|
99
|
+
"lib/**/*.d.ts",
|
|
100
|
+
"src",
|
|
101
|
+
"cordis.patch.yml",
|
|
102
|
+
"README.md"
|
|
103
|
+
],
|
|
104
|
+
"scripts": {
|
|
105
|
+
"build": "tsc -p tsconfig.build.json && tsdown",
|
|
106
|
+
"typecheck": "tsc --noEmit -p tsconfig.build.json",
|
|
107
|
+
"test": "vitest run"
|
|
108
|
+
},
|
|
109
|
+
"license": "MIT"
|
|
110
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
export class ScoreCache {
|
|
29
|
+
private readonly file: string
|
|
30
|
+
private readonly maxEntries: number
|
|
31
|
+
private loaded = false
|
|
32
|
+
private entries = new Map<string, CachedPairScore>()
|
|
33
|
+
private readonly inflight = new Map<string, Promise<CachedPairScore>>()
|
|
34
|
+
private writing: Promise<void> = Promise.resolve()
|
|
35
|
+
|
|
36
|
+
constructor(file: string, maxEntries: number) {
|
|
37
|
+
this.file = file
|
|
38
|
+
this.maxEntries = maxEntries
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async load(): Promise<void> {
|
|
42
|
+
if (this.loaded) return
|
|
43
|
+
this.loaded = true
|
|
44
|
+
try {
|
|
45
|
+
const document = JSON.parse(await readFile(this.file, 'utf8')) as CacheDocument
|
|
46
|
+
if (document.version !== 1 || typeof document.entries !== 'object' || document.entries === null) return
|
|
47
|
+
this.entries = new Map(Object.entries(document.entries).map(([key, value]) => [key, { ...value, scoringMode: value.scoringMode ?? 'explicit-tag' }]))
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getOrCreate(key: string, create: () => Promise<CachedPairScore>): Promise<{ value: CachedPairScore; hit: boolean }> {
|
|
54
|
+
await this.load()
|
|
55
|
+
const cached = this.entries.get(key)
|
|
56
|
+
if (cached !== undefined) return { value: cached, hit: true }
|
|
57
|
+
const existing = this.inflight.get(key)
|
|
58
|
+
if (existing !== undefined) return { value: await existing, hit: true }
|
|
59
|
+
const pending = create()
|
|
60
|
+
this.inflight.set(key, pending)
|
|
61
|
+
try {
|
|
62
|
+
const value = await pending
|
|
63
|
+
this.entries.set(key, value)
|
|
64
|
+
this.trim()
|
|
65
|
+
await this.persist()
|
|
66
|
+
return { value, hit: false }
|
|
67
|
+
} finally {
|
|
68
|
+
this.inflight.delete(key)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private trim(): void {
|
|
73
|
+
if (this.entries.size <= this.maxEntries) return
|
|
74
|
+
const sorted = [...this.entries].sort((a, b) => a[1].createdAt - b[1].createdAt)
|
|
75
|
+
for (let index = 0; index < sorted.length - this.maxEntries; index += 1) this.entries.delete(sorted[index]![0])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async persist(): Promise<void> {
|
|
79
|
+
const snapshot: CacheDocument = { version: 1, entries: Object.fromEntries(this.entries) }
|
|
80
|
+
this.writing = this.writing.then(async () => {
|
|
81
|
+
await mkdir(dirname(this.file), { recursive: true })
|
|
82
|
+
const temporary = this.file + '.tmp-' + process.pid
|
|
83
|
+
await writeFile(temporary, JSON.stringify(snapshot), 'utf8')
|
|
84
|
+
try { await rename(temporary, this.file) } catch (error) { await unlink(temporary).catch(() => {}); throw error }
|
|
85
|
+
})
|
|
86
|
+
await this.writing
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +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
|
+
})
|
package/src/caller.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { BlockAssembler, ReasoningEffortId, createUserMessage, deepFreeze, type ContentBlock, type FinishReason, type LlmRuntime } from '@deepseek-ai/dsh-llm'
|
|
2
|
+
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
|
3
|
+
import type { CompletionLogprobs } from './core.ts'
|
|
4
|
+
import { TopLogprobCapabilityCache, TopLogprobsUnsupportedError, callTopLogprobs, resolveTopLogprobRoute } from './top-logprobs.ts'
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
|
|
7
|
+
export interface VerifierClientConfig {
|
|
8
|
+
ctx: Context
|
|
9
|
+
llm: LlmRuntime
|
|
10
|
+
attachments: AttachmentStore
|
|
11
|
+
topLogprobCapabilities: TopLogprobCapabilityCache
|
|
12
|
+
provider: string
|
|
13
|
+
model: string
|
|
14
|
+
reasoningEffort?: string
|
|
15
|
+
maxTokens: number
|
|
16
|
+
timeoutMs: number
|
|
17
|
+
maxRetries: number
|
|
18
|
+
retryBaseDelayMs: number
|
|
19
|
+
limiter?: RequestLimiter
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface VerifierImage {
|
|
23
|
+
data: Uint8Array
|
|
24
|
+
mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UsageStats {
|
|
28
|
+
calls: number
|
|
29
|
+
attempts: number
|
|
30
|
+
retries: number
|
|
31
|
+
inputTokens: number
|
|
32
|
+
cachedInputTokens: number
|
|
33
|
+
outputTokens: number
|
|
34
|
+
reasoningTokens: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ScoringMode = 'top-logprobs' | 'explicit-tag'
|
|
38
|
+
export interface VerifierCompletion extends CompletionLogprobs { usage: UsageStats; scoringMode: ScoringMode }
|
|
39
|
+
|
|
40
|
+
function failureMessage(finish: FinishReason): string | undefined {
|
|
41
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') return finish.failure.message
|
|
42
|
+
if (finish.kind === 'max-tokens') return 'verifier response reached max tokens before completing its answer'
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
47
|
+
if (signal?.aborted) throw signal.reason
|
|
48
|
+
await new Promise<void>((resolve, reject) => {
|
|
49
|
+
const timer = setTimeout(resolve, ms)
|
|
50
|
+
const abort = () => { clearTimeout(timer); reject(signal?.reason) }
|
|
51
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function usage(attempts: number, value = {} as { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; reasoningTokens?: number }): UsageStats {
|
|
56
|
+
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 }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function callExplicitTag(config: VerifierClientConfig, prompt: string, signal?: AbortSignal, images?: readonly VerifierImage[]): Promise<VerifierCompletion> {
|
|
60
|
+
let attempt = 0
|
|
61
|
+
while (true) {
|
|
62
|
+
attempt += 1
|
|
63
|
+
const controller = new AbortController()
|
|
64
|
+
const timeout = setTimeout(() => controller.abort(new Error('llm-verifier: request timed out')), config.timeoutMs)
|
|
65
|
+
const abort = () => controller.abort(signal?.reason)
|
|
66
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
67
|
+
try {
|
|
68
|
+
const content: ContentBlock[] = [{ type: 'text', text: prompt }]
|
|
69
|
+
for (const image of images ?? []) {
|
|
70
|
+
const ref = await config.attachments.saveImage({ data: image.data, mediaType: image.mediaType })
|
|
71
|
+
content.push({ type: 'image', attachment: ref })
|
|
72
|
+
}
|
|
73
|
+
const messages = [createUserMessage({ content, source: { kind: 'plugin', plugin: 'dsh-llm-verifier' } })]
|
|
74
|
+
const assembler = new BlockAssembler()
|
|
75
|
+
const options = deepFreeze({
|
|
76
|
+
provider: config.provider,
|
|
77
|
+
model: config.model,
|
|
78
|
+
...(config.reasoningEffort ? { reasoningEffort: ReasoningEffortId(config.reasoningEffort) } : {}),
|
|
79
|
+
messages,
|
|
80
|
+
maxTokens: config.maxTokens,
|
|
81
|
+
temperature: 1,
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
})
|
|
84
|
+
for await (const chunk of config.llm.stream(options)) assembler.push(chunk)
|
|
85
|
+
const failed = failureMessage(assembler.finish)
|
|
86
|
+
if (failed !== undefined) throw new Error('llm-verifier: model call failed: ' + failed)
|
|
87
|
+
const text = assembler.blocks().filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text').map(block => block.text).join('')
|
|
88
|
+
if (!text.trim()) throw new Error('llm-verifier: selected DSH model produced no text')
|
|
89
|
+
// DSH adapters expose provider-neutral text/usage but not top-logprob candidates.
|
|
90
|
+
// extractScore() therefore uses the model's explicit final A–T tags.
|
|
91
|
+
return { text, tokens: [], positions: [], scoringMode: 'explicit-tag', usage: usage(attempt, assembler.usage) }
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (signal?.aborted) throw signal.reason
|
|
94
|
+
if (attempt > config.maxRetries || !(error instanceof Error) || !/rate|quota|timeout|timed out|temporar|network|fetch|socket|5dd/i.test(error.message)) throw error
|
|
95
|
+
await delay(Math.min(30000, config.retryBaseDelayMs * 2 ** (attempt - 1) * (0.8 + Math.random() * 0.4)), signal)
|
|
96
|
+
} finally {
|
|
97
|
+
clearTimeout(timeout)
|
|
98
|
+
signal?.removeEventListener('abort', abort)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class RequestLimiter {
|
|
104
|
+
private active = 0
|
|
105
|
+
private readonly queue: Array<() => void> = []
|
|
106
|
+
constructor(readonly limit: number) {}
|
|
107
|
+
async run<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
108
|
+
if (this.active >= this.limit) await new Promise<void>((resolve, reject) => {
|
|
109
|
+
const enter = () => { signal?.removeEventListener('abort', abort); resolve() }
|
|
110
|
+
const abort = () => { const index = this.queue.indexOf(enter); if (index >= 0) this.queue.splice(index, 1); reject(signal?.reason) }
|
|
111
|
+
this.queue.push(enter); signal?.addEventListener('abort', abort, { once: true })
|
|
112
|
+
})
|
|
113
|
+
if (signal?.aborted) throw signal.reason
|
|
114
|
+
this.active += 1
|
|
115
|
+
try { return await operation() } finally { this.active -= 1; this.queue.shift()?.() }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function callAutomatic(config: VerifierClientConfig, prompt: string, signal?: AbortSignal, images?: readonly VerifierImage[]): Promise<VerifierCompletion> {
|
|
120
|
+
if (!config.topLogprobCapabilities.isUnsupported(config.provider, config.model)) {
|
|
121
|
+
const route = await resolveTopLogprobRoute(config.ctx, config.provider)
|
|
122
|
+
if (route !== undefined) {
|
|
123
|
+
try { return await callTopLogprobs(route, config.model, prompt, config.maxTokens, config.reasoningEffort, signal, images) }
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (!(error instanceof TopLogprobsUnsupportedError)) throw error
|
|
126
|
+
config.topLogprobCapabilities.markUnsupported(config.provider, config.model)
|
|
127
|
+
}
|
|
128
|
+
} else config.topLogprobCapabilities.markUnsupported(config.provider, config.model)
|
|
129
|
+
}
|
|
130
|
+
return callExplicitTag(config, prompt, signal, images)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function callVerifier(config: VerifierClientConfig, prompt: string, signal?: AbortSignal, images?: readonly VerifierImage[]): Promise<VerifierCompletion> {
|
|
134
|
+
const invoke = () => callAutomatic(config, prompt, signal, images)
|
|
135
|
+
return config.limiter === undefined ? invoke() : config.limiter.run(invoke, signal)
|
|
136
|
+
}
|
|
137
|
+
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] }
|
|
138
|
+
export function emptyUsage(): UsageStats { return { calls: 0, attempts: 0, retries: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0 } }
|
package/src/client.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
2
|
+
import type { ModelProviderGroup, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
|
3
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
4
|
+
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
5
|
+
import { Button, Input, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
6
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
7
|
+
|
|
8
|
+
const NS = 'llm-verifier'
|
|
9
|
+
interface Values { provider: string; model: string; reasoningEffort?: string; maxTokens: number; maxConcurrency: number; maxRetries: number; timeoutMs: number; cacheMaxEntries: number; estimatedInputUsdPerMillion: number; estimatedOutputUsdPerMillion: number }
|
|
10
|
+
interface Loaded { groups: ModelProviderGroup[]; settings: SettingsNamespaceView; writable: boolean; failures: string[] }
|
|
11
|
+
const shell: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 18, padding: '8px 4px 32px', color: 'var(--dsw-text-primary)' }
|
|
12
|
+
const card: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 0, padding: '16px 16px 0', border: '1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.16))', borderRadius: 12, background: 'var(--dsw-alias-bg-module, rgba(20, 31, 57, 0.42))', overflow: 'hidden' }
|
|
13
|
+
const sectionTitle: React.CSSProperties = { display: 'flex', gap: 10, alignItems: 'center', padding: '0 0 12px', borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.16))' }
|
|
14
|
+
const row: React.CSSProperties = { display: 'grid', gridTemplateColumns: 'minmax(150px, 1fr) minmax(220px, 1.4fr)', gap: 18, alignItems: 'center', padding: '14px 0', borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.16))' }
|
|
15
|
+
const selectStyle: React.CSSProperties = { width: '100%', minHeight: 38, padding: '0 12px', borderRadius: 10, color: 'var(--dsw-text-primary)', background: 'var(--dsw-surface-sunken)', border: '1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.16))' }
|
|
16
|
+
function record(value: unknown): Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {} }
|
|
17
|
+
function values(view: SettingsNamespaceView): Values { const v=record(view.value); return { provider:String(v.provider??''),model:String(v.model??''),...(typeof v.reasoningEffort==='string'?{reasoningEffort:v.reasoningEffort}:{}),maxTokens:Number(v.maxTokens??32768),maxConcurrency:Number(v.maxConcurrency??8),maxRetries:Number(v.maxRetries??3),timeoutMs:Number(v.timeoutMs??300000),cacheMaxEntries:Number(v.cacheMaxEntries??10000),estimatedInputUsdPerMillion:Number(v.estimatedInputUsdPerMillion??0),estimatedOutputUsdPerMillion:Number(v.estimatedOutputUsdPerMillion??0) } }
|
|
18
|
+
function message(error: unknown): string { return error instanceof Error ? error.message : String(error) }
|
|
19
|
+
function Label({title,help}:{title:string;help:string}) { return <div><div style={{fontWeight:600}}>{title}</div><div style={{fontSize:12,color:'var(--dsw-text-secondary)',marginTop:3}}>{help}</div></div> }
|
|
20
|
+
|
|
21
|
+
function VerifierSettings({ api }:{api:any}) {
|
|
22
|
+
const [loaded,setLoaded]=useState<Loaded|null>(null); const [draft,setDraft]=useState<Values|null>(null); const [busy,setBusy]=useState(false); const [error,setError]=useState<string|null>(null); const [saved,setSaved]=useState(false)
|
|
23
|
+
const load=async()=>{setError(null);try{const [m,s]=await Promise.all([api.llm.models({}),api.settings.describe({})]);if(!m.result.ok)throw new Error(m.result.error.message);if(!s.result.ok)throw new Error(s.result.error.message);const view=s.result.value.namespaces.find((x:SettingsNamespaceView)=>x.ns===NS);if(!view)throw new Error('Verifier settings namespace is not registered. Restart the DSH host.');const next={groups:m.result.value.groups,settings:view,writable:s.result.value.writable,failures:m.result.value.failures.map((f:any)=>f.name+': '+f.message)};setLoaded(next);setDraft(values(view))}catch(e){setError(message(e))}}
|
|
24
|
+
useEffect(()=>{void load()},[])
|
|
25
|
+
const models=useMemo(()=>loaded?.groups.find(g=>g.id===draft?.provider)?.models??[],[loaded,draft?.provider])
|
|
26
|
+
const selected=models.find(m=>m.id===draft?.model); const efforts=selected?.reasoning?.efforts??[]
|
|
27
|
+
const patch=<K extends keyof Values>(key:K,value:Values[K])=>setDraft(v=>v?{...v,[key]:value}:v)
|
|
28
|
+
const save=async()=>{if(!loaded||!draft)return;setBusy(true);setSaved(false);setError(null);try{const section={...record(loaded.settings.user),...draft};if(!draft.reasoningEffort)delete section.reasoningEffort;const res=await api.settings.update({ns:NS,patch:section,expectedRevision:loaded.settings.revision});if(!res.result.ok)throw new Error(res.result.error.message);setLoaded(v=>v?{...v,settings:res.result.value}:v);setDraft(values(res.result.value));setSaved(true)}catch(e){setError(message(e))}finally{setBusy(false)}}
|
|
29
|
+
if(!loaded||!draft)return <div style={shell}><h2>LLM Verifier</h2><p>{error??'正在读取 DSH 模型和设置…'}</p>{error&&<Button onClick={()=>void load()}>重试</Button>}</div>
|
|
30
|
+
const numeric=(key:keyof Values,min=0)=><Input type="number" min={min} value={String(draft[key])} onChange={e=>patch(key,Number(e.target.value) as never)} />
|
|
31
|
+
return <div style={shell}>
|
|
32
|
+
<div><h2 style={{margin:'0 0 6px'}}>LLM Verifier</h2><p style={{margin:0,color:'var(--dsw-text-secondary)'}}>选择任意已在 DSH「模型」页配置并启用的模型作为独立裁判。设置实时生效。</p></div>
|
|
33
|
+
<div style={card}><div style={sectionTitle}><StateDot state="done"/><strong>裁判模型</strong></div>
|
|
34
|
+
<div style={row}><Label title="供应商" help="只显示当前 DSH 中可路由的供应商"/><select style={selectStyle} value={draft.provider} onChange={e=>{const provider=e.target.value;const first=loaded.groups.find(g=>g.id===provider)?.models[0];setDraft({...draft,provider,...(first?{model:first.id,reasoningEffort:first.reasoning?.defaultEffort}:{})})}}>{loaded.groups.map(g=><option key={g.id} value={g.id}>{g.name} · {g.id}</option>)}</select></div>
|
|
35
|
+
<div style={row}><Label title="模型" help="模型目录来自 DSH adapter,选择结果会持久化"/><select style={selectStyle} value={draft.model} onChange={e=>{const model=e.target.value;const found=models.find(m=>m.id===model);setDraft({...draft,model,...(found?.reasoning?.defaultEffort?{reasoningEffort:found.reasoning.defaultEffort}:{reasoningEffort:undefined})})}}>{models.map(m=><option key={m.id} value={m.id}>{m.name} · {m.id}</option>)}</select></div>
|
|
36
|
+
<div style={row}><Label title="推理强度" help="由所选模型 adapter 声明;留空使用模型默认值"/><select style={selectStyle} value={draft.reasoningEffort??''} onChange={e=>patch('reasoningEffort',e.target.value||undefined)}><option value="">模型默认</option>{efforts.map(e=><option key={e.id} value={e.id}>{e.name}</option>)}</select></div>
|
|
37
|
+
<div style={row}><Label title="最大输出 Token" help="每个裁判请求的输出上限"/>{numeric('maxTokens',1)}</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div style={card}><div style={sectionTitle}><strong>执行控制</strong></div><div style={row}><Label title="最大并发" help="所有 verifier 工具共享的请求并发上限"/>{numeric('maxConcurrency',1)}</div><div style={row}><Label title="最多重试" help="短暂网络、限流和服务端错误的重试次数"/>{numeric('maxRetries',0)}</div><div style={row}><Label title="请求超时(毫秒)" help="单个模型请求的超时时间"/>{numeric('timeoutMs',1)}</div><div style={row}><Label title="缓存条目上限" help="持久评分缓存保留的最大条目数"/>{numeric('cacheMaxEntries',1)}</div></div>
|
|
40
|
+
<div style={card}><div style={sectionTitle}><strong>费用估算(每百万 Token,USD)</strong></div><div style={row}><Label title="输入价格" help="仅用于结果中的 estimatedCostUsd"/>{numeric('estimatedInputUsdPerMillion',0)}</div><div style={row}><Label title="输出价格" help="仅用于结果中的 estimatedCostUsd"/>{numeric('estimatedOutputUsdPerMillion',0)}</div></div>
|
|
41
|
+
{loaded.failures.length>0&&<div style={{...card,borderColor:'var(--dsw-alias-state-warn-primary, #d9a441)',paddingBottom:16}}><strong>部分模型目录读取失败</strong>{loaded.failures.map(x=><div key={x}>{x}</div>)}</div>}
|
|
42
|
+
{error&&<div style={{color:'var(--dsw-danger)'}}>{error}</div>}{saved&&<div style={{color:'var(--dsw-success)'}}>已保存,后续 verifier 调用将使用新设置。</div>}
|
|
43
|
+
<div style={{display:'flex',gap:10}}><Button disabled={busy||!loaded.writable} onClick={()=>void save()}>{busy?'保存中…':'保存设置'}</Button><Button variant="outline" disabled={busy} onClick={()=>void load()}>重新载入</Button></div>
|
|
44
|
+
</div>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const inject=['slots','connection']
|
|
48
|
+
export function apply(ctx:ClientContext):void { const connection=ctx.get('connection') as any; ctx.slots.inject('settings.section',()=>ctx.slots.register({name:'settings.section',id:'llm-verifier',order:35,label:'LLM Verifier',inject:()=>({api:connection.api})},VerifierSettings as never)) }
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
3
|
+
import z from 'schemastery'
|
|
4
|
+
|
|
5
|
+
export const VERIFIER_SETTINGS_NAMESPACE = settingsNamespace('llm-verifier')
|
|
6
|
+
|
|
7
|
+
export interface Config {
|
|
8
|
+
provider?: string
|
|
9
|
+
model?: string
|
|
10
|
+
reasoningEffort?: string
|
|
11
|
+
maxTokens?: number
|
|
12
|
+
timeoutMs?: number
|
|
13
|
+
maxConcurrency?: number
|
|
14
|
+
maxRetries?: number
|
|
15
|
+
retryBaseDelayMs?: number
|
|
16
|
+
cacheDir?: string
|
|
17
|
+
cacheMaxEntries?: number
|
|
18
|
+
estimatedInputUsdPerMillion?: number
|
|
19
|
+
estimatedOutputUsdPerMillion?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ResolvedConfig {
|
|
23
|
+
provider: string
|
|
24
|
+
model: string
|
|
25
|
+
reasoningEffort?: string
|
|
26
|
+
maxTokens: number
|
|
27
|
+
timeoutMs: number
|
|
28
|
+
maxConcurrency: number
|
|
29
|
+
maxRetries: number
|
|
30
|
+
retryBaseDelayMs: number
|
|
31
|
+
cacheDir: string
|
|
32
|
+
cacheMaxEntries: number
|
|
33
|
+
estimatedInputUsdPerMillion: number
|
|
34
|
+
estimatedOutputUsdPerMillion: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const Config: z<Config> = z.object({
|
|
38
|
+
provider: z.string().default('deepseek-official'),
|
|
39
|
+
model: z.string().default('deepseek-v4-flash'),
|
|
40
|
+
reasoningEffort: z.string(),
|
|
41
|
+
maxTokens: z.number().step(1).min(1).default(32768),
|
|
42
|
+
timeoutMs: z.number().step(1).min(1).default(300000),
|
|
43
|
+
maxConcurrency: z.number().step(1).min(1).default(8),
|
|
44
|
+
maxRetries: z.number().step(1).min(0).default(3),
|
|
45
|
+
retryBaseDelayMs: z.number().step(1).min(1).default(500),
|
|
46
|
+
cacheDir: z.string().default('.dsh-verifier-cache'),
|
|
47
|
+
cacheMaxEntries: z.number().step(1).min(1).default(10000),
|
|
48
|
+
estimatedInputUsdPerMillion: z.number().min(0).default(0),
|
|
49
|
+
estimatedOutputUsdPerMillion: z.number().min(0).default(0),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export function resolveConfig(config: Config = {}): ResolvedConfig {
|
|
53
|
+
const provider = (config.provider ?? 'deepseek-official').trim()
|
|
54
|
+
const model = (config.model ?? 'deepseek-v4-flash').trim()
|
|
55
|
+
if (!provider) throw new Error('llm-verifier: provider must be non-empty')
|
|
56
|
+
if (!model) throw new Error('llm-verifier: model must be non-empty')
|
|
57
|
+
const values = {
|
|
58
|
+
maxTokens: config.maxTokens ?? 32768,
|
|
59
|
+
timeoutMs: config.timeoutMs ?? 300000,
|
|
60
|
+
maxConcurrency: config.maxConcurrency ?? 8,
|
|
61
|
+
retryBaseDelayMs: config.retryBaseDelayMs ?? 500,
|
|
62
|
+
cacheMaxEntries: config.cacheMaxEntries ?? 10000,
|
|
63
|
+
}
|
|
64
|
+
for (const [name, value] of Object.entries(values)) if (!Number.isSafeInteger(value) || value <= 0) throw new Error('llm-verifier: ' + name + ' must be a positive safe integer')
|
|
65
|
+
const maxRetries = config.maxRetries ?? 3
|
|
66
|
+
if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) throw new Error('llm-verifier: maxRetries must be a non-negative safe integer')
|
|
67
|
+
const cacheDir = (config.cacheDir ?? '.dsh-verifier-cache').trim()
|
|
68
|
+
if (!cacheDir) throw new Error('llm-verifier: cacheDir must be non-empty')
|
|
69
|
+
const estimatedInputUsdPerMillion = config.estimatedInputUsdPerMillion ?? 0
|
|
70
|
+
const estimatedOutputUsdPerMillion = config.estimatedOutputUsdPerMillion ?? 0
|
|
71
|
+
if (![estimatedInputUsdPerMillion, estimatedOutputUsdPerMillion].every(value => Number.isFinite(value) && value >= 0)) throw new Error('llm-verifier: estimated token prices must be finite non-negative numbers')
|
|
72
|
+
const reasoningEffort = config.reasoningEffort?.trim()
|
|
73
|
+
return { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}), maxRetries, cacheDir, estimatedInputUsdPerMillion, estimatedOutputUsdPerMillion, ...values }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function installVerifierSettings(ctx: Context, entry: ResolvedConfig, onChange: () => void): () => ResolvedConfig {
|
|
77
|
+
let source = () => entry
|
|
78
|
+
installSettingsSection(ctx, VERIFIER_SETTINGS_NAMESPACE, Config as z<ResolvedConfig>, entry, {
|
|
79
|
+
setSource(current) { source = current },
|
|
80
|
+
onChange,
|
|
81
|
+
validate(value) { resolveConfig(value) },
|
|
82
|
+
})
|
|
83
|
+
return () => resolveConfig(source())
|
|
84
|
+
}
|