feat(stage-ui): add Ollama thinking mode config and provider-side think mapping (#1140)

This commit is contained in:
Garfield Lee
2026-03-05 20:46:45 +08:00
committed by GitHub
parent c1d315b84f
commit 86c0d9d28a
8 changed files with 260 additions and 14 deletions
@@ -494,6 +494,17 @@ pages:
placeholder: Key
value:
placeholder: Value
thinking-mode:
label: Thinking Mode
description: >
Controls Ollama thinking behavior. GPT-OSS only supports low/medium/high levels.
options:
auto: Auto (provider default)
disable: Disable
enable: Enable
low: Low
medium: Medium
high: High
validators:
actions:
validate: Validate
@@ -470,6 +470,17 @@ pages:
placeholder: 请求头名字
value:
placeholder: 请求头数值
thinking-mode:
label: 思考模式
description: >
控制 Ollama 的思考行为。GPT-OSS 仅支持 low/medium/high 级别。
options:
auto: 自动(服务默认)
disable: 关闭
enable: 开启
low:
medium:
high:
validators:
actions:
validate: 验证
@@ -11,7 +11,7 @@ import {
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldKeyValues } from '@proj-airi/ui'
import { FieldKeyValues, FieldSelect } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
@@ -42,6 +42,14 @@ const {
} = useProviderValidation(providerId)
const headers = ref<{ key: string, value: string }[]>(Object.entries(providers.value[providerId]?.headers || {}).map(([key, value]) => ({ key, value } as { key: string, value: string })) || [{ key: '', value: '' }])
const thinkingMode = computed({
get: () => providers.value[providerId]?.thinkingMode || 'auto',
set: (value: string) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].thinkingMode = value
},
})
function addKeyValue(headers: { key: string, value: string }[], key: string, value: string) {
if (!headers)
@@ -82,6 +90,7 @@ async function refetch() {
try {
const validationResult = await providerMetadata.value.validators.validateProviderConfig({
baseUrl: baseUrl.value,
thinkingMode: thinkingMode.value,
headers: headers.value.filter(header => header.key !== '').reduce((acc, header) => {
acc[header.key] = header.value
return acc
@@ -101,9 +110,7 @@ async function refetch() {
}
}
watch([baseUrl, headers], refetch, { immediate: true })
watch(headers, refetch, { deep: true })
watch([baseUrl, thinkingMode, headers], refetch, { immediate: true, deep: true })
onMounted(() => {
providersStore.initializeProvider(providerId)
@@ -117,6 +124,10 @@ onMounted(() => {
if (headers.value.length === 0) {
headers.value = [{ key: '', value: '' }]
}
if (!providers.value[providerId].thinkingMode) {
providers.value[providerId].thinkingMode = 'auto'
}
})
</script>
@@ -139,6 +150,20 @@ onMounted(() => {
</ProviderBasicSettings>
<ProviderAdvancedSettings :title="t('settings.pages.providers.common.section.advanced.title')">
<FieldSelect
v-model="thinkingMode"
:label="t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.label')"
:description="t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.description')"
:options="[
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.auto'), value: 'auto' },
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.disable'), value: 'disable' },
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.enable'), value: 'enable' },
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.low'), value: 'low' },
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.medium'), value: 'medium' },
{ label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.high'), value: 'high' },
]"
/>
<FieldKeyValues
v-model="headers"
:label="t('settings.pages.providers.common.section.advanced.fields.field.headers.label')"
@@ -17,7 +17,7 @@ import {
} from '@proj-airi/stage-ui/components'
import { getDefinedProvider, getSchemaDefault, getValidatorsOfProvider, validateProvider } from '@proj-airi/stage-ui/libs'
import { useProviderCatalogStore } from '@proj-airi/stage-ui/stores/provider-catalog'
import { Button, Callout, FieldInput, FieldKeyValues } from '@proj-airi/ui'
import { Button, Callout, FieldInput, FieldKeyValues, FieldSelect } from '@proj-airi/ui'
import { useCloned, useDebounceFn } from '@vueuse/core'
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from 'reka-ui'
import { computed, onMounted, ref, watch } from 'vue'
@@ -106,12 +106,33 @@ const schemaFields = computed(() => {
const label = typeof meta.labelLocalized === 'string' ? meta.labelLocalized : ''
const description = typeof meta.descriptionLocalized === 'string' ? meta.descriptionLocalized : schema.description
const placeholder = typeof meta.placeholderLocalized === 'string' ? meta.placeholderLocalized : ''
const options = Array.isArray(meta.options)
? meta.options
.map((item) => {
if (!item || typeof item !== 'object')
return null
const option = item as { label?: unknown, value?: unknown }
if (typeof option.label !== 'string')
return null
if (typeof option.value !== 'string' && typeof option.value !== 'number')
return null
return {
label: option.label,
value: option.value,
}
})
.filter((item): item is { label: string, value: string | number } => item !== null)
: undefined
return {
key,
schema,
section,
type,
options,
label,
description,
placeholder,
@@ -434,6 +455,14 @@ function handleDeleteProvider() {
:placeholder="field.placeholder"
:required="field.required"
/>
<FieldSelect
v-else-if="field.type === 'select'"
v-model="providerConfigEdit.config[field.key]"
:label="field.label"
:description="field.description"
:placeholder="field.placeholder"
:options="field.options"
/>
<FieldInput
v-else
v-model="providerConfigEdit.config[field.key]"
@@ -31,7 +31,7 @@ function toggleVisible() {
</div>
</button>
</template>
<div mt-4>
<div mt-4 space-y-2>
<slot />
</div>
</Collapsible>
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { providerOllama, resolveOllamaThink } from './index'
describe('providerOllama.resolveOllamaThink', () => {
it('should return undefined for auto mode', () => {
expect(resolveOllamaThink('qwen3:8b', 'auto')).toBeUndefined()
})
it('should map disable/enable to booleans for non gpt-oss models', () => {
expect(resolveOllamaThink('qwen3:8b', 'disable')).toBe(false)
expect(resolveOllamaThink('qwen3:8b', 'enable')).toBe(true)
})
it('should map disable/enable to levels for gpt-oss models', () => {
expect(resolveOllamaThink('gpt-oss:20b', 'disable')).toBe('low')
expect(resolveOllamaThink('gpt-oss:20b', 'enable')).toBe('medium')
})
it('should pass level modes through unchanged', () => {
expect(resolveOllamaThink('qwen3:8b', 'low')).toBe('low')
expect(resolveOllamaThink('qwen3:8b', 'medium')).toBe('medium')
expect(resolveOllamaThink('qwen3:8b', 'high')).toBe('high')
})
it('should fallback invalid values to auto mode', () => {
expect(resolveOllamaThink('qwen3:8b', 'invalid')).toBeUndefined()
})
})
describe('providerOllama.createProvider chat options', () => {
it('should not set think when thinkingMode is auto', () => {
const provider = providerOllama.createProvider({
baseUrl: 'http://localhost:11434/v1/',
thinkingMode: 'auto',
}) as any
const chatOptions = provider.chat('qwen3:8b') as Record<string, unknown>
expect('think' in chatOptions).toBe(false)
})
it('should set think=false for non gpt-oss when thinkingMode is disable', () => {
const provider = providerOllama.createProvider({
baseUrl: 'http://localhost:11434/v1/',
thinkingMode: 'disable',
}) as any
const chatOptions = provider.chat('qwen3:8b') as Record<string, unknown>
expect(chatOptions.think).toBe(false)
})
it('should set think=medium for gpt-oss when thinkingMode is enable', () => {
const provider = providerOllama.createProvider({
baseUrl: 'http://localhost:11434/v1/',
thinkingMode: 'enable',
}) as any
const chatOptions = provider.chat('gpt-oss:20b') as Record<string, unknown>
expect(chatOptions.think).toBe('medium')
})
it('should set think=low for gpt-oss when thinkingMode is disable', () => {
const provider = providerOllama.createProvider({
baseUrl: 'http://localhost:11434/v1/',
thinkingMode: 'disable',
}) as any
const chatOptions = provider.chat('gpt-oss:20b') as Record<string, unknown>
expect(chatOptions.think).toBe('low')
})
})
@@ -4,7 +4,61 @@ import { z } from 'zod'
import { createOpenAICompatibleValidators } from '../../validators'
import { defineProvider } from '../registry'
export const providerOllama = defineProvider({
type OllamaThinkValue = boolean | 'high' | 'low' | 'medium'
type OllamaThinkingMode = 'auto' | 'disable' | 'enable' | 'high' | 'low' | 'medium'
const ollamaConfigSchema = z.object({
baseUrl: z.string()
.default('http://localhost:11434/v1/'),
thinkingMode: z.enum(['auto', 'disable', 'enable', 'low', 'medium', 'high'])
.default('auto'),
headers: z.record(z.string(), z.string())
.optional(),
})
type OllamaConfig = z.input<typeof ollamaConfigSchema>
function isGptOssModel(model: string): boolean {
return model.toLowerCase().includes('gpt-oss')
}
function normalizeOllamaThinkingMode(value: unknown): OllamaThinkingMode {
switch (value) {
case 'auto':
case 'disable':
case 'enable':
case 'high':
case 'low':
case 'medium':
return value
default:
return 'auto'
}
}
export function resolveOllamaThink(model: string, modeRaw: unknown): OllamaThinkValue | undefined {
const mode = normalizeOllamaThinkingMode(modeRaw)
const isGptOss = isGptOssModel(model)
switch (mode) {
case 'auto':
return undefined
case 'disable':
// NOTICE: GPT-OSS ignores boolean `think`, so "disable" degrades to `low`.
return isGptOss ? 'low' : false
case 'enable':
// NOTICE: GPT-OSS requires levels; map generic "enable" to medium effort.
return isGptOss ? 'medium' : true
case 'low':
case 'medium':
case 'high':
return mode
default:
return undefined
}
}
export const providerOllama = defineProvider<OllamaConfig>({
id: 'ollama',
order: 2,
name: 'Ollama',
@@ -14,16 +68,47 @@ export const providerOllama = defineProvider({
tasks: ['chat'],
icon: 'i-lobe-icons:ollama',
createProviderConfig: ({ t }) => z.object({
baseUrl: z.string()
.default('http://localhost:11434/v1/')
createProviderConfig: ({ t }) => ollamaConfigSchema.extend({
baseUrl: ollamaConfigSchema.shape.baseUrl
.meta({
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.label'),
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.description'),
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.placeholder'),
}),
headers: z.record(z.string(), z.string())
.optional()
thinkingMode: ollamaConfigSchema.shape.thinkingMode
.meta({
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.label'),
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.description'),
section: 'advanced',
type: 'select',
options: [
{
label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.auto'),
value: 'auto',
},
{
label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.disable'),
value: 'disable',
},
{
label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.enable'),
value: 'enable',
},
{
label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.low'),
value: 'low',
},
{
label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.medium'),
value: 'medium',
},
{
label: t('settings.pages.providers.catalog.edit.config.common.fields.field.thinking-mode.options.high'),
value: 'high',
},
],
}),
headers: ollamaConfigSchema.shape.headers
.meta({
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.headers.label'),
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.headers.description'),
@@ -32,7 +117,20 @@ export const providerOllama = defineProvider({
}),
}),
createProvider(config) {
return createOllama('', config.baseUrl)
const baseProvider = createOllama('', config.baseUrl)
return {
...baseProvider,
chat(model: string) {
const chatOptions = baseProvider.chat(model)
const think = resolveOllamaThink(model, config.thinkingMode)
if (think === undefined)
return chatOptions
return { ...chatOptions, think }
},
}
},
validationRequiredWhen: () => true,
validators: {
+2 -1
View File
@@ -44,6 +44,7 @@ function streamOptionsToolsCompatibilityOk(model: string, chatProvider: ChatProv
async function streamFrom(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) {
const headers = options?.headers
const chatConfig = chatProvider.chat(model)
const sanitized = sanitizeMessages(messages as unknown[])
const resolveTools = async () => {
@@ -97,7 +98,7 @@ async function streamFrom(model: string, chatProvider: ChatProvider, messages: M
try {
streamText({
...chatProvider.chat(model),
...chatConfig,
maxSteps: 10,
messages: sanitized,
headers,