feat(stage-ui): add Ollama thinking mode config and provider-side think mapping (#1140)
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user