Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 62 additions & 14 deletions WebUI/electron/subprocesses/openVINOBackendService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,38 @@ interface OvmsServerProcess {
isReady: boolean
healthEndpointUrl: string
}
/**
* Resolve the OVMS `--tool_parser` for a model from its models list entry.
* Falls back to 'hermes3' when the model is unknown or has no override.
*/
export function resolveOvmsToolParser(
modelRepoId: string,
models?: Array<{ name: string; toolParser?: string }>,
): string {
const fallback = 'hermes3'
const parser = models?.find((m) => m.name === modelRepoId)?.toolParser
return parser || fallback
}

/**
* Resolve the OVMS `--reasoning_parser` for a model from its models list entry.
* If reasoningParser is set, returns that parser. If supportsReasoning is false,
* returns undefined (omit flag). Otherwise falls back to 'qwen3'.
*/
export function resolveOvmsReasoningParser(
modelRepoId: string,
models?: Array<{ name: string; reasoningParser?: string; supportsReasoning?: boolean }>,
): string | undefined {
const fallback = 'qwen3'
const model = models?.find((m) => m.name === modelRepoId)
if (model?.reasoningParser) {
return model.reasoningParser
}
if (model?.supportsReasoning === false) {
return undefined
}
return fallback
}

export class OpenVINOBackendService implements ApiService {
readonly name = 'openvino-backend' as BackendServiceName
Expand Down Expand Up @@ -2040,27 +2072,40 @@ export class OpenVINOBackendService implements ApiService {
}
}

/**
* Resolve the OVMS `--tool_parser` for a model from its models.json entry.
* Falls back to 'hermes3' when the model is unknown or has no override
* (Qwen3.x and most chat models emit Hermes-style <tool_call> tags).
*/
private async resolveToolParser(modelRepoId: string): Promise<string> {
const fallback = 'hermes3'
try {
const models = await resolveModels(this.settings)
const parser = models.find((m) => m.name === modelRepoId)?.toolParser
if (parser) {
const parser = resolveOvmsToolParser(modelRepoId, models)
if (parser !== fallback) {
this.appLogger.info(`Using tool_parser '${parser}' for ${modelRepoId}`, this.name)
return parser
}
return parser
} catch (error) {
this.appLogger.warn(
`Failed to resolve tool_parser for ${modelRepoId}, using '${fallback}': ${error}`,
this.name,
)
return fallback
}
}

private async resolveReasoningParser(modelRepoId: string): Promise<string | undefined> {
const fallback = 'qwen3'
try {
const models = await resolveModels(this.settings)
const parser = resolveOvmsReasoningParser(modelRepoId, models)
if (parser) {
this.appLogger.info(`Using reasoning_parser '${parser}' for ${modelRepoId}`, this.name)
}
return parser
} catch (error) {
this.appLogger.warn(
`Failed to resolve reasoning_parser for ${modelRepoId}, using '${fallback}': ${error}`,
this.name,
)
return fallback
}
return fallback
}

// Model server management methods
Expand All @@ -2072,6 +2117,7 @@ export class OpenVINOBackendService implements ApiService {
const selectedDevice = this.devices.find((d) => d.selected)?.id || 'AUTO'
const maxPromptLen = contextSize ?? 8192
const toolParser = await this.resolveToolParser(modelRepoId)
const reasoningParser = await this.resolveReasoningParser(modelRepoId)
const servedModelName = modelRepoId.split('/').join('---')

this.appLogger.info(
Expand All @@ -2087,7 +2133,7 @@ export class OpenVINOBackendService implements ApiService {
'--rest_workers',
'4',
'--source_model',
modelRepoId.split('/').join('---'),
servedModelName,
'--model_repository_path',
path.resolve(path.join(this.baseDir, 'models', 'LLM', 'openvino')),
'--target_device',
Expand All @@ -2096,12 +2142,14 @@ export class OpenVINOBackendService implements ApiService {
'text_generation',
'--tool_parser',
toolParser,
'--reasoning_parser',
'qwen3',
'--cache_dir',
'cache',
]

if (reasoningParser) {
args.push('--reasoning_parser', reasoningParser)
}

args.push('--cache_dir', 'cache')

if (selectedDevice.startsWith('NPU')) {
args.push('--max_prompt_len', maxPromptLen.toString())
}
Expand Down
85 changes: 85 additions & 0 deletions WebUI/electron/test/subprocesses/openVinoParsers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'

vi.mock('electron', () => ({
app: {
isPackaged: false,
},
BrowserWindow: class {},
dialog: {},
net: {},
}))

import {
resolveOvmsReasoningParser,
resolveOvmsToolParser,
} from '../../subprocesses/openVINOBackendService'

describe('resolveOvmsToolParser', () => {
const models = [
{ name: 'OpenVINO/gpt-oss-20b-int4-ov', toolParser: 'gptoss' },
{ name: 'OpenVINO/Mistral-7B-Instruct-v0.3-int4-cw-ov', toolParser: 'mistral' },
{ name: 'OpenVINO/Qwen3-4B-int4-ov' },
]

it('uses the model toolParser override when specified', () => {
expect(resolveOvmsToolParser('OpenVINO/gpt-oss-20b-int4-ov', models)).toBe('gptoss')
expect(resolveOvmsToolParser('OpenVINO/Mistral-7B-Instruct-v0.3-int4-cw-ov', models)).toBe(
'mistral',
)
})

it('falls back to hermes3 when toolParser is omitted', () => {
expect(resolveOvmsToolParser('OpenVINO/Qwen3-4B-int4-ov', models)).toBe('hermes3')
})

it('falls back to hermes3 for unknown models or empty models list', () => {
expect(resolveOvmsToolParser('unknown/model', models)).toBe('hermes3')
expect(resolveOvmsToolParser('OpenVINO/gpt-oss-20b-int4-ov', [])).toBe('hermes3')
expect(resolveOvmsToolParser('OpenVINO/gpt-oss-20b-int4-ov', undefined)).toBe('hermes3')
})
})

describe('resolveOvmsReasoningParser', () => {
const models = [
{
name: 'OpenVINO/gpt-oss-20b-int4-ov',
supportsReasoning: true,
reasoningParser: 'gptoss',
},
{
name: 'OpenVINO/Qwen3-4B-int4-ov',
supportsReasoning: true,
},
{
name: 'OpenVINO/Mistral-7B-Instruct-v0.3-int4-cw-ov',
supportsReasoning: false,
},
{
name: 'OpenVINO/DeepSeek-R1-Distill-Qwen-1.5B-int4-ov',
supportsReasoning: true,
},
]

it('uses the explicit reasoningParser override (e.g. gptoss)', () => {
expect(resolveOvmsReasoningParser('OpenVINO/gpt-oss-20b-int4-ov', models)).toBe('gptoss')
})

it('defaults to qwen3 for models supporting reasoning without explicit parser', () => {
expect(resolveOvmsReasoningParser('OpenVINO/Qwen3-4B-int4-ov', models)).toBe('qwen3')
expect(
resolveOvmsReasoningParser('OpenVINO/DeepSeek-R1-Distill-Qwen-1.5B-int4-ov', models),
).toBe('qwen3')
})

it('returns undefined for models where reasoning is unsupported', () => {
expect(
resolveOvmsReasoningParser('OpenVINO/Mistral-7B-Instruct-v0.3-int4-cw-ov', models),
).toBeUndefined()
})

it('falls back to qwen3 when model is unknown or list is empty', () => {
expect(resolveOvmsReasoningParser('unknown/model', models)).toBe('qwen3')
expect(resolveOvmsReasoningParser('OpenVINO/gpt-oss-20b-int4-ov', [])).toBe('qwen3')
expect(resolveOvmsReasoningParser('OpenVINO/gpt-oss-20b-int4-ov', undefined)).toBe('qwen3')
})
})
1 change: 1 addition & 0 deletions WebUI/external/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@
"type": "openVINO",
"supportsToolCalling": true,
"toolParser": "gptoss",
"reasoningParser": "gptoss",
"supportsVision": false,
"supportsReasoning": true,
"maxContextSize": 131072
Expand Down
1 change: 1 addition & 0 deletions WebUI/src/assets/js/store/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type Model = {
backend?: LlmBackend
supportsToolCalling?: boolean
toolParser?: string // OVMS --tool_parser override; defaults to 'hermes3'
reasoningParser?: string // OVMS --reasoning_parser override; defaults to 'qwen3' when supportsReasoning is true
supportsVision?: boolean
supportsReasoning?: boolean
supportsThinkingToggle?: boolean // Template honors enable_thinking toggle (Qwen3 family, gemma4)
Expand Down
58 changes: 58 additions & 0 deletions WebUI/src/lib/ttsVoiceSeed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { randomVoiceSeed, seedForVoice, stableVoiceSeed } from './ttsVoiceSeed'

describe('ttsVoiceSeed', () => {
describe('randomVoiceSeed', () => {
it('produces integers within valid non-negative 31-bit range', () => {
for (let i = 0; i < 50; i++) {
const seed = randomVoiceSeed()
expect(Number.isInteger(seed)).toBe(true)
expect(seed).toBeGreaterThanOrEqual(0)
expect(seed).toBeLessThanOrEqual(0x7fffffff)
}
})
})

describe('stableVoiceSeed', () => {
it('is deterministic for the same name and instruct', () => {
const seed1 = stableVoiceSeed('Hans', 'Deep gravelly voice')
const seed2 = stableVoiceSeed('Hans', 'Deep gravelly voice')
expect(seed1).toBe(seed2)
})

it('is case-insensitive for voice name and trims whitespace', () => {
const seed1 = stableVoiceSeed('Hans', 'Deep gravelly voice')
const seed2 = stableVoiceSeed(' hans ', 'Deep gravelly voice')
expect(seed1).toBe(seed2)
})

it('produces different seeds for different instructions', () => {
const seed1 = stableVoiceSeed('Hans', 'Deep gravelly voice')
const seed2 = stableVoiceSeed('Hans', 'High pitched and cheerful voice')
expect(seed1).not.toBe(seed2)
})
})

describe('seedForVoice', () => {
it('returns the explicit seed when present', () => {
expect(
seedForVoice({
name: 'Hans',
instruct: 'Deep gravelly voice',
seed: 424242,
}),
).toBe(424242)
})

it('falls back to stableVoiceSeed when seed is undefined', () => {
const expected = stableVoiceSeed('Hans', 'Deep gravelly voice')
expect(
seedForVoice({
name: 'Hans',
instruct: 'Deep gravelly voice',
seed: undefined,
}),
).toBe(expected)
})
})
})
33 changes: 33 additions & 0 deletions WebUI/src/lib/ttsVoiceSeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Qwen3TtsSavedVoice } from '@/assets/js/qwen3TtsConstants'

/**
* Generate a random non-negative 31-bit integer seed for voice design sampling.
*/
export function randomVoiceSeed(): number {
return Math.floor(Math.random() * 0x7fffffff)
}

/**
* Derive a stable non-negative 31-bit integer seed from a voice's name and instruct text.
* Uses a djb2-style string hash so the same description produces a consistent voice.
*/
export function stableVoiceSeed(name: string, instruct: string): number {
const combined = `${name.trim().toLowerCase()}:${instruct.trim()}`
let hash = 5381
for (let i = 0; i < combined.length; i++) {
hash = ((hash * 33) ^ combined.charCodeAt(i)) >>> 0
}
return hash & 0x7fffffff
}

/**
* Return a saved voice's explicit seed, or derive a stable one from its description.
*/
export function seedForVoice(
voice: Pick<Qwen3TtsSavedVoice, 'name' | 'instruct' | 'seed'>,
): number {
if (typeof voice.seed === 'number' && Number.isFinite(voice.seed)) {
return voice.seed
}
return stableVoiceSeed(voice.name, voice.instruct)
}
5 changes: 5 additions & 0 deletions WebUI/src/types/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const ovmsToolParsers = [
'gemma4',
] as const

// Reasoning parsers supported by OpenVINO Model Server (OVMS).
export const ovmsReasoningParsers = ['qwen3', 'gptoss', 'lfm2', 'gemma4'] as const

export const ModelSchema = z.object({
name: z.string(),
mmproj: z.string().optional(),
Expand All @@ -34,6 +37,8 @@ export const ModelSchema = z.object({
supportsToolCalling: z.boolean().optional(),
// OVMS tool-call parser override; defaults to 'hermes3' when omitted.
toolParser: z.enum(ovmsToolParsers).optional(),
// OVMS reasoning parser override; defaults to 'qwen3' when supportsReasoning is true.
reasoningParser: z.enum(ovmsReasoningParsers).optional(),
supportsVision: z.boolean().optional(),
maxContextSize: z.number().optional(),
npuSupport: z.boolean().optional(),
Expand Down