diff --git a/packages/inference/src/schemas/llamacpp-config.ts b/packages/inference/src/schemas/llamacpp-config.ts index 5188c76d5f..c0ae00d3cb 100644 --- a/packages/inference/src/schemas/llamacpp-config.ts +++ b/packages/inference/src/schemas/llamacpp-config.ts @@ -75,6 +75,18 @@ export const llmConfigBaseSchema = z.object({ * Ignored by text-only models. Default is `"sequential"`. */ image_tile_mode: z.enum(['disabled', 'batched', 'sequential']).optional(), + /** + * idefics3-style image preprocessing rule (multimodal models only): + * - `"on"`: round the image's long side up to a whole number of slices and + * cap it, so an image smaller than the cap keeps its own resolution and + * becomes far fewer slices. + * - `"off"`: always stretch the long side to the cap. + * When unset, the model's own GGUF value is used. Ignored with a warning by + * models that do not use idefics3-style preprocessing. Changes the number of + * image tokens, and therefore both accuracy and encode time, so a checkpoint + * whose GGUF omits the key needs this set to preprocess correctly. + */ + image_no_upscale: z.enum(['on', 'off']).optional(), /** * Run the multimodal projector (mmproj / vision encoder) on the GPU * (multimodal models only). `true` forces GPU, `false` forces CPU. When diff --git a/packages/inference/test/llm-config-schema.test.ts b/packages/inference/test/llm-config-schema.test.ts index d25284f856..5d56697811 100644 --- a/packages/inference/test/llm-config-schema.test.ts +++ b/packages/inference/test/llm-config-schema.test.ts @@ -150,6 +150,34 @@ test('llmConfigSchema: explicit image_tile_mode overrides the default', (t) => { if (result.success) t.is(result.data.image_tile_mode, 'batched') }) +test('llmConfigBaseSchema: accepts valid image_no_upscale values', (t) => { + t.is(llmConfigBaseSchema.safeParse({ image_no_upscale: 'on' }).success, true) + t.is(llmConfigBaseSchema.safeParse({ image_no_upscale: 'off' }).success, true) +}) + +test('llmConfigBaseSchema: rejects invalid image_no_upscale values', (t) => { + t.is(llmConfigBaseSchema.safeParse({ image_no_upscale: true }).success, false) + t.is(llmConfigBaseSchema.safeParse({ image_no_upscale: 1 }).success, false) + t.is(llmConfigBaseSchema.safeParse({ image_no_upscale: 'yes' }).success, false) +}) + +// Unset must stay unset. The addon reads absence as fabric's -1 sentinel, meaning +// "use the model's own GGUF value"; a default here would force one rule on every +// model and silently change preprocessing for existing callers. +test('llmConfigBaseSchema: image_no_upscale is optional and has no default', (t) => { + t.is(llmConfigBaseSchema.safeParse({}).success, true) + const result = llmConfigSchema.safeParse({}) + t.is(result.success, true) + if (result.success) t.is(result.data.image_no_upscale, undefined) +}) + +// The regression this guards: load-model.ts validates modelConfig with +// llmConfigBaseSchema.strict(), so a field present in the SDK copy of this schema but +// missing here is rejected before it ever reaches the addon. +test('loadBuiltinModelOptions: strict validation admits image_no_upscale', (t) => { + t.is(llmConfigBaseSchema.strict().safeParse({ image_no_upscale: 'on' }).success, true) +}) + test('llmConfigBaseSchema: accepts mmproj-use-gpu boolean', (t) => { const enabled = llmConfigBaseSchema.safeParse({ 'mmproj-use-gpu': true }) t.is(enabled.success, true) diff --git a/packages/sdk-python/src/tetherto/qvac_sdk/_generated/models/__init__.py b/packages/sdk-python/src/tetherto/qvac_sdk/_generated/models/__init__.py index dcefc148b0..a477dc2923 100644 --- a/packages/sdk-python/src/tetherto/qvac_sdk/_generated/models/__init__.py +++ b/packages/sdk-python/src/tetherto/qvac_sdk/_generated/models/__init__.py @@ -489,6 +489,7 @@ LoadModelSrcRequestLlamacppCompletion, LoadModelSrcRequestLlamacppCompletionDelegate, LoadModelSrcRequestLlamacppCompletionModelConfig, + LoadModelSrcRequestLlamacppCompletionModelConfigImageNoUpscale, LoadModelSrcRequestLlamacppCompletionModelConfigImageTileMode, LoadModelSrcRequestLlamacppCompletionModelConfigMainGpu, LoadModelSrcRequestLlamacppCompletionModelConfigProjectionModelSrc, @@ -1275,6 +1276,7 @@ "LoadModelSrcRequestLlamacppCompletion", "LoadModelSrcRequestLlamacppCompletionDelegate", "LoadModelSrcRequestLlamacppCompletionModelConfig", + "LoadModelSrcRequestLlamacppCompletionModelConfigImageNoUpscale", "LoadModelSrcRequestLlamacppCompletionModelConfigImageTileMode", "LoadModelSrcRequestLlamacppCompletionModelConfigMainGpu", "LoadModelSrcRequestLlamacppCompletionModelConfigProjectionModelSrc", diff --git a/packages/sdk-python/src/tetherto/qvac_sdk/_generated/models/_internal.py b/packages/sdk-python/src/tetherto/qvac_sdk/_generated/models/_internal.py index 2cf3632413..3781d15b6a 100644 --- a/packages/sdk-python/src/tetherto/qvac_sdk/_generated/models/_internal.py +++ b/packages/sdk-python/src/tetherto/qvac_sdk/_generated/models/_internal.py @@ -6625,6 +6625,11 @@ class LoadModelSrcRequestLlamacppCompletionModelConfigImageTileMode(Enum): sequential = "sequential" +class LoadModelSrcRequestLlamacppCompletionModelConfigImageNoUpscale(Enum): + on = "on" + off = "off" + + class LoadModelSrcRequestLlamacppCompletionModelConfig(GeneratedBaseModel): ctx_size: float | None = None temp: Annotated[float | None, Field(ge=0.0, le=2.0)] = None @@ -6672,6 +6677,10 @@ class LoadModelSrcRequestLlamacppCompletionModelConfig(GeneratedBaseModel): LoadModelSrcRequestLlamacppCompletionModelConfigImageTileMode | None, Field(title="LoadModelSrcRequestLlamacppCompletionModelConfigImageTileMode"), ] = None + image_no_upscale: Annotated[ + LoadModelSrcRequestLlamacppCompletionModelConfigImageNoUpscale | None, + Field(title="LoadModelSrcRequestLlamacppCompletionModelConfigImageNoUpscale"), + ] = None mmproj_use_gpu: Annotated[bool | None, Field(alias="mmproj-use-gpu")] = None diff --git a/packages/sdk/contract/schema.json b/packages/sdk/contract/schema.json index f2daed6462..ce6c584ce4 100644 --- a/packages/sdk/contract/schema.json +++ b/packages/sdk/contract/schema.json @@ -8264,6 +8264,11 @@ "enum": ["disabled", "batched", "sequential"], "title": "LoadModelSrcRequestLlamacppCompletionModelConfigImageTileMode" }, + "image_no_upscale": { + "type": "string", + "enum": ["on", "off"], + "title": "LoadModelSrcRequestLlamacppCompletionModelConfigImageNoUpscale" + }, "mmproj-use-gpu": { "type": "boolean" } diff --git a/packages/sdk/e2e/tests/batch-completion-tests.ts b/packages/sdk/e2e/tests/batch-completion-tests.ts index 7fa49301ea..deb560ead9 100644 --- a/packages/sdk/e2e/tests/batch-completion-tests.ts +++ b/packages/sdk/e2e/tests/batch-completion-tests.ts @@ -80,7 +80,7 @@ const markerDeterministic: GenerationParams = { ...deterministic, predict: 32 } const visionDeterministic: GenerationParams = { temp: 0, seed: 42, - predict: 48 + predict: 128 } const ELEPHANT_IMAGE_TERMS = ['elephant', 'tusk', 'trunk'] diff --git a/packages/sdk/e2e/tests/desktop/consumer.ts b/packages/sdk/e2e/tests/desktop/consumer.ts index cf4ce54346..24cd56d648 100644 --- a/packages/sdk/e2e/tests/desktop/consumer.ts +++ b/packages/sdk/e2e/tests/desktop/consumer.ts @@ -37,8 +37,8 @@ import { PI05_BASE_Q_AGGRESSIVE, GROOT_Q5_VF16, GROOT_MULTI_Q5_VF16, - SMOLVLM2_500M_MULTIMODAL_Q8_0, - MMPROJ_SMOLVLM2_500M_MULTIMODAL_Q8_0, + VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, + MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0, FLUX_2_KLEIN_4B_Q4_0, FLUX_2_KLEIN_4B_VAE, QWEN3_4B_Q4_K_M, @@ -482,21 +482,33 @@ resources.define('bci', { }) resources.define('vision', { - constant: SMOLVLM2_500M_MULTIMODAL_Q8_0, + constant: VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, type: 'llamacpp-completion', config: { ctx_size: 4096, - projectionModelSrc: MMPROJ_SMOLVLM2_500M_MULTIMODAL_Q8_0 + image_no_upscale: 'on', + projectionModelSrc: MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0 } }) resources.define('vision-batch', { - constant: SMOLVLM2_500M_MULTIMODAL_Q8_0, + constant: VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, type: 'llamacpp-completion', config: { ctx_size: 2048, parallel: 2, - projectionModelSrc: MMPROJ_SMOLVLM2_500M_MULTIMODAL_Q8_0 + image_no_upscale: 'on', + projectionModelSrc: MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0 + } +}) + +resources.define('vision-upscale', { + constant: VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, + type: 'llamacpp-completion', + config: { + ctx_size: 4096, + image_no_upscale: 'off', + projectionModelSrc: MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0 } }) diff --git a/packages/sdk/e2e/tests/electron/consumer.ts b/packages/sdk/e2e/tests/electron/consumer.ts index cb4a031957..0535195a30 100644 --- a/packages/sdk/e2e/tests/electron/consumer.ts +++ b/packages/sdk/e2e/tests/electron/consumer.ts @@ -46,8 +46,8 @@ import { PARAKEET_INDIC_CONFORMER_CTC_Q4_0, PARAKEET_SORTFORMER_4SPK_V2_1_Q4_0, PARAKEET_EOU_120M_V1_Q4_0, - SMOLVLM2_500M_MULTIMODAL_Q8_0, - MMPROJ_SMOLVLM2_500M_MULTIMODAL_Q8_0, + VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, + MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0, QWEN3_5_0_8B_MULTIMODAL_Q4_K_M, GEMMA4_2B_MULTIMODAL_Q4_K_M, BCI_WINDOWED @@ -434,21 +434,33 @@ resources.define('bci', { }) resources.define('vision', { - constant: SMOLVLM2_500M_MULTIMODAL_Q8_0, + constant: VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, type: 'llamacpp-completion', config: { - ctx_size: 1024, - projectionModelSrc: MMPROJ_SMOLVLM2_500M_MULTIMODAL_Q8_0 + ctx_size: 4096, + image_no_upscale: 'on', + projectionModelSrc: MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0 } }) resources.define('vision-batch', { - constant: SMOLVLM2_500M_MULTIMODAL_Q8_0, + constant: VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, type: 'llamacpp-completion', config: { ctx_size: 2048, parallel: 2, - projectionModelSrc: MMPROJ_SMOLVLM2_500M_MULTIMODAL_Q8_0 + image_no_upscale: 'on', + projectionModelSrc: MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0 + } +}) + +resources.define('vision-upscale', { + constant: VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, + type: 'llamacpp-completion', + config: { + ctx_size: 4096, + image_no_upscale: 'off', + projectionModelSrc: MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0 } }) diff --git a/packages/sdk/e2e/tests/mobile/consumer.ts b/packages/sdk/e2e/tests/mobile/consumer.ts index 106fc19477..cafb1b7ca5 100644 --- a/packages/sdk/e2e/tests/mobile/consumer.ts +++ b/packages/sdk/e2e/tests/mobile/consumer.ts @@ -33,8 +33,8 @@ import { PARAKEET_CTC_0_6B_Q4_0, PARAKEET_SORTFORMER_4SPK_V2_1_Q4_0, PARAKEET_EOU_120M_V1_Q4_0, - SMOLVLM2_500M_MULTIMODAL_Q8_0, - MMPROJ_SMOLVLM2_500M_MULTIMODAL_Q8_0, + VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, + MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0, SMOLVLA_LIBERO_VISION_Q8 } from '@qvac/sdk' import { ResourceManager } from '../shared/resource-manager.js' @@ -415,21 +415,33 @@ resources.define('parakeet-eou', { }) resources.define('vision', { - constant: SMOLVLM2_500M_MULTIMODAL_Q8_0, + constant: VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, type: 'llamacpp-completion', config: { ctx_size: 4096, - projectionModelSrc: MMPROJ_SMOLVLM2_500M_MULTIMODAL_Q8_0 + image_no_upscale: 'on', + projectionModelSrc: MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0 } }) resources.define('vision-batch', { - constant: SMOLVLM2_500M_MULTIMODAL_Q8_0, + constant: VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, type: 'llamacpp-completion', config: { ctx_size: 2048, parallel: 2, - projectionModelSrc: MMPROJ_SMOLVLM2_500M_MULTIMODAL_Q8_0 + image_no_upscale: 'on', + projectionModelSrc: MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0 + } +}) + +resources.define('vision-upscale', { + constant: VISIONPSY_NANO_460M_MULTIMODAL_Q4_K_M, + type: 'llamacpp-completion', + config: { + ctx_size: 4096, + image_no_upscale: 'off', + projectionModelSrc: MMPROJ_VISIONPSY_NANO_460M_MULTIMODAL_Q8_0 } }) diff --git a/packages/sdk/e2e/tests/mobile/executors/vision-executor.ts b/packages/sdk/e2e/tests/mobile/executors/vision-executor.ts index edf2a92fe0..8fc8cd7e8f 100644 --- a/packages/sdk/e2e/tests/mobile/executors/vision-executor.ts +++ b/packages/sdk/e2e/tests/mobile/executors/vision-executor.ts @@ -16,6 +16,9 @@ export class MobileVisionExecutor extends ModelAssetExecutor protected handlers = Object.fromEntries( visionTests.map((test) => { + if (test.testId === 'vision-image-no-upscale') { + return [test.testId, this.imageNoUpscale.bind(this)] + } if (test.testId.endsWith('-streaming')) { return [test.testId, this.streaming.bind(this)] } @@ -33,6 +36,55 @@ export class MobileVisionExecutor extends ModelAssetExecutor super(resources) } + private async getPromptTokens(modelId: string, params: VisionParams) { + const history = await this.resolveAttachments(params.history) + return callWhenAddonIdle(async () => { + const result = completion({ + modelId, + history, + stream: false, + ...(params.generationParams && { generationParams: params.generationParams }) + } as never) + await result.text + const stats = await result.stats + if (typeof stats?.promptTokens !== 'number' || stats.promptTokens <= 0) { + throw new Error(`Completion stats missing promptTokens. Got: ${JSON.stringify(stats)}`) + } + return stats.promptTokens + }) + } + + async imageNoUpscale(params: unknown, _expectation: Expectation): Promise { + const p = params as VisionParams + + try { + const noUpscaleModelId = await this.resources.ensureLoaded('vision') + const noUpscaleTokens = await this.getPromptTokens(noUpscaleModelId, p) + + await this.resources.evict('vision') + + const upscaleModelId = await this.resources.ensureLoaded('vision-upscale') + const upscaleTokens = await this.getPromptTokens(upscaleModelId, p) + + if (noUpscaleTokens * 2 >= upscaleTokens) { + return { + passed: false, + output: + `image_no_upscale on (${noUpscaleTokens}) should use less than half the prompt tokens ` + + `of off (${upscaleTokens})` + } + } + + return { + passed: true, + output: `image_no_upscale reduced prompt tokens from ${upscaleTokens} to ${noUpscaleTokens}` + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + return { passed: false, output: `image_no_upscale comparison failed: ${errorMsg}` } + } + } + private async loadImageAssets() { if (!this.imageAssets) { // @ts-ignore - assets.ts is generated at consumer build time diff --git a/packages/sdk/e2e/tests/shared/executors/node/vision-executor.ts b/packages/sdk/e2e/tests/shared/executors/node/vision-executor.ts index 57aa6715be..f37d235660 100644 --- a/packages/sdk/e2e/tests/shared/executors/node/vision-executor.ts +++ b/packages/sdk/e2e/tests/shared/executors/node/vision-executor.ts @@ -16,6 +16,9 @@ export class VisionExecutor extends AbstractModelExecutor { protected handlers = Object.fromEntries( visionTests.map((test) => { + if (test.testId === 'vision-image-no-upscale') { + return [test.testId, this.imageNoUpscale.bind(this)] + } if (test.testId.endsWith('-streaming')) { return [test.testId, this.streaming.bind(this)] } @@ -26,6 +29,55 @@ export class VisionExecutor extends AbstractModelExecutor { }) ) as never + private async getPromptTokens(modelId: string, params: VisionParams) { + const history = this.resolveAttachments(params.history) + return callWhenAddonIdle(async () => { + const result = completion({ + modelId, + history, + stream: false, + ...(params.generationParams && { generationParams: params.generationParams }) + } as never) + await result.text + const stats = await result.stats + if (typeof stats?.promptTokens !== 'number' || stats.promptTokens <= 0) { + throw new Error(`Completion stats missing promptTokens. Got: ${JSON.stringify(stats)}`) + } + return stats.promptTokens + }) + } + + async imageNoUpscale(params: unknown, _expectation: Expectation): Promise { + const p = params as VisionParams + + try { + const noUpscaleModelId = await this.resources.ensureLoaded('vision') + const noUpscaleTokens = await this.getPromptTokens(noUpscaleModelId, p) + + await this.resources.evict('vision') + + const upscaleModelId = await this.resources.ensureLoaded('vision-upscale') + const upscaleTokens = await this.getPromptTokens(upscaleModelId, p) + + if (noUpscaleTokens * 2 >= upscaleTokens) { + return { + passed: false, + output: + `image_no_upscale on (${noUpscaleTokens}) should use less than half the prompt tokens ` + + `of off (${upscaleTokens})` + } + } + + return { + passed: true, + output: `image_no_upscale reduced prompt tokens from ${upscaleTokens} to ${noUpscaleTokens}` + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + return { passed: false, output: `image_no_upscale comparison failed: ${errorMsg}` } + } + } + private resolveAttachments(history: VisionParams['history']) { return history.map((msg) => { if (!msg.attachments?.length) return msg diff --git a/packages/sdk/e2e/tests/vision-tests.ts b/packages/sdk/e2e/tests/vision-tests.ts index b8f8926b02..472eb47543 100644 --- a/packages/sdk/e2e/tests/vision-tests.ts +++ b/packages/sdk/e2e/tests/vision-tests.ts @@ -61,6 +61,26 @@ export const visionStats = createVisionTest( { generationParams: { temp: 0, seed: 42 } } ) +export const visionImageNoUpscale: TestDefinition = { + testId: 'vision-image-no-upscale', + params: { + history: [ + { + role: 'user', + content: 'Describe this image briefly.', + attachments: [{ path: 'shared-test-data/images/small-64.jpg' }] + } + ], + generationParams: { temp: 0, top_k: 1, seed: 42, predict: 8 } + }, + expectation: { validation: 'function', fn: () => true }, + metadata: { + category: 'vision', + dependency: 'vision', + estimatedDurationMs: 120000 + } +} + export const visionFormatPng = createVisionTest( 'vision-format-png', 'Describe this image.', @@ -103,7 +123,7 @@ export const visionTextExtraction = createVisionTest( 'Read the text in this image. Reply with only the text.', 'sign.jpg', { validation: 'contains-all', contains: ['hello'] }, - { generationParams: { temp: 0, top_k: 1, seed: 42, predict: 16 } } + { generationParams: { temp: 0, top_k: 1, seed: 42, predict: 128 } } ) export const visionSceneUnderstanding = createVisionTest( @@ -205,6 +225,7 @@ export const visionTests = [ visionBasic, visionStreaming, visionStats, + visionImageNoUpscale, visionFormatPng, visionFormatWebp, visionLargeImage, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6dc0f333af..e3ff475990 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -209,20 +209,20 @@ "@qvac/audiogen-ggml": "^0.2.1", "@qvac/asr-ggml": "^0.3.0", "@qvac/bci-whispercpp": "^0.7.1", - "@qvac/classification-ggml": "^0.18.0", + "@qvac/classification-ggml": "^0.19.1", "@qvac/decoder-audio": "^0.5.0", "@qvac/diffusion-cpp": "^0.17.0", - "@qvac/embed-llamacpp": "^0.32.0", + "@qvac/embed-llamacpp": "^0.33.0", "@qvac/error": "^0.1.1", "@qvac/langdetect-text": "^0.1.2", - "@qvac/llm-llamacpp": "^0.43.0", + "@qvac/llm-llamacpp": "^0.44.0", "@qvac/logging": "^0.1.0", - "@qvac/ocr-ggml": "^0.16.0", + "@qvac/ocr-ggml": "^0.17.0", "@qvac/rag": "^0.6.4", "@qvac/registry-client": "^0.6.1", - "@qvac/translation-nmtcpp": "^0.8.0", - "@qvac/tts-ggml": "^0.7.0", - "@qvac/vla-ggml": "^0.19.0", + "@qvac/translation-nmtcpp": "^0.9.0", + "@qvac/tts-ggml": "^0.7.4", + "@qvac/vla-ggml": "^0.20.0", "bare-abort-controller": "^1.0.0", "bare-cpu-info": "0.1.1", "bare-crypto": "^1.15.0", diff --git a/packages/sdk/schemas/llamacpp-config.ts b/packages/sdk/schemas/llamacpp-config.ts index 3481358ccb..21a3aaf71e 100644 --- a/packages/sdk/schemas/llamacpp-config.ts +++ b/packages/sdk/schemas/llamacpp-config.ts @@ -75,6 +75,18 @@ export const llmConfigBaseSchema = z.object({ * Ignored by text-only models. Default is `"sequential"`. */ image_tile_mode: z.enum(['disabled', 'batched', 'sequential']).optional(), + /** + * idefics3-style image preprocessing rule (multimodal models only): + * - `"on"`: round the image's long side up to a whole number of slices and + * cap it, so an image smaller than the cap keeps its own resolution and + * becomes far fewer slices. + * - `"off"`: always stretch the long side to the cap. + * When unset, the model's own GGUF value is used. Ignored with a warning by + * models that do not use idefics3-style preprocessing. Changes the number of + * image tokens, and therefore both accuracy and encode time, so a checkpoint + * whose GGUF omits the key needs this set to preprocess correctly. + */ + image_no_upscale: z.enum(['on', 'off']).optional(), /** * Run the multimodal projector (mmproj / vision encoder) on the GPU * (multimodal models only). `true` forces GPU, `false` forces CPU. When diff --git a/packages/sdk/test/unit/llm-config-schema.test.ts b/packages/sdk/test/unit/llm-config-schema.test.ts index d25284f856..6c04f6f7b5 100644 --- a/packages/sdk/test/unit/llm-config-schema.test.ts +++ b/packages/sdk/test/unit/llm-config-schema.test.ts @@ -150,6 +150,24 @@ test('llmConfigSchema: explicit image_tile_mode overrides the default', (t) => { if (result.success) t.is(result.data.image_tile_mode, 'batched') }) +test('llmConfigBaseSchema: accepts valid image_no_upscale values', (t) => { + t.is(llmConfigBaseSchema.safeParse({ image_no_upscale: 'on' }).success, true) + t.is(llmConfigBaseSchema.safeParse({ image_no_upscale: 'off' }).success, true) +}) + +test('llmConfigBaseSchema: rejects invalid image_no_upscale values', (t) => { + t.is(llmConfigBaseSchema.safeParse({ image_no_upscale: 'maybe' }).success, false) + t.is(llmConfigBaseSchema.safeParse({ image_no_upscale: true }).success, false) +}) + +// No default: unset must stay unset so the addon keeps the model's own GGUF value +// rather than being forced to base preprocessing. +test('llmConfigSchema: image_no_upscale has no default', (t) => { + const result = llmConfigSchema.safeParse({}) + t.is(result.success, true) + if (result.success) t.is(result.data.image_no_upscale, undefined) +}) + test('llmConfigBaseSchema: accepts mmproj-use-gpu boolean', (t) => { const enabled = llmConfigBaseSchema.safeParse({ 'mmproj-use-gpu': true }) t.is(enabled.success, true)