feat(models): add model test debugger for saved model configs

Let admins send real requests against configured chat, embedding, rerank,
VLM, and ASR models from settings, with thinking controls shown only when
the model supports them.
This commit is contained in:
wizardchen
2026-06-25 16:55:17 +08:00
committed by lyingbug
parent 122408b283
commit 2c44a41f2a
14 changed files with 1438 additions and 7 deletions
+42 -1
View File
@@ -1,4 +1,4 @@
import { get, post, put, del } from '../../utils/request';
import { get, post, postUpload, put, del } from '../../utils/request';
import i18n from '@/i18n'
const t = (key: string) => i18n.global.t(key)
@@ -142,6 +142,47 @@ export function deleteModel(id: string): Promise<void> {
});
}
export interface ModelDebugOptions {
system_prompt?: string
temperature?: number
top_p?: number
max_tokens?: number
thinking?: boolean
}
export interface ModelDebugResult {
ok: boolean
elapsed_ms: number
request: Record<string, unknown>
raw_response: unknown
observations: Record<string, unknown>
error?: string
}
export async function debugModel(
id: string,
data: {
input?: string
documents?: string[]
options?: ModelDebugOptions
file?: File | null
},
): Promise<ModelDebugResult> {
const form = new FormData()
form.append('input', data.input || '')
form.append('documents', JSON.stringify(data.documents || []))
form.append('options', JSON.stringify(data.options || {}))
if (data.file) form.append('file', data.file)
const response: any = await postUpload(
`/api/v1/models/${id}/debug`,
form,
undefined,
{ timeout: 300000 },
)
if (response?.success && response?.data) return response.data
throw new Error(response?.message || t('error.model.getFailed'))
}
// ----------------------------------------------------------------------------
// Model credential subresource. See mcp-service.ts for the matching MCP API
// shape and the design notes in internal/handler/dto/mcp.go.
@@ -0,0 +1,697 @@
<template>
<SettingDrawer
v-model:visible="drawerVisible"
:title="$t('modelSettings.debug.title')"
:description="$t('modelSettings.debug.description')"
icon="play-circle-stroke"
width="560px"
:min-width="480"
:max-width="900"
storage-key="setting-drawer:width:model-debug"
:confirm-text="$t('modelSettings.debug.run')"
:confirm-loading="running"
:confirm-disabled="!canRun"
:cancel-text="$t('common.close')"
@confirm="runDebug"
>
<template v-if="result" #footer-left>
<t-button variant="outline" @click="copyResult">
<template #icon><t-icon name="file-copy" /></template>
{{ $t('modelSettings.debug.copyResult') }}
</t-button>
</template>
<div class="model-debug">
<section class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('modelSettings.debug.groupModel') }}</h4>
<div v-if="availableModelTypes.length > 1" class="form-item">
<div class="model-type-options" role="radiogroup" :aria-label="$t('modelSettings.debug.modelType')">
<button
v-for="option in availableModelTypes"
:key="option.value"
type="button"
class="model-type-option"
:class="{ 'is-active': selectedModelType === option.value }"
role="radio"
:aria-checked="selectedModelType === option.value"
@click="selectModelType(option.value)"
>
<t-icon :name="option.icon" class="model-type-option__icon" />
<span class="model-type-option__label">{{ option.label }}</span>
</button>
</div>
</div>
<div class="form-item">
<label class="form-label">{{ $t('modelSettings.debug.model') }}</label>
<t-select
v-model="selectedModelId"
filterable
:placeholder="$t('modelSettings.debug.modelPlaceholder')"
:disabled="filteredModels.length === 0"
@change="resetResult"
>
<t-option
v-for="model in filteredModels"
:key="model.id"
:value="model.id!"
:label="modelLabel(model)"
>
<div class="model-option">
<span class="model-option__name">{{ modelLabel(model) }}</span>
<span class="model-option__meta">{{ vendorLabel(model) }}</span>
</div>
</t-option>
</t-select>
<p v-if="filteredModels.length === 0" class="form-desc">
{{ $t('modelSettings.debug.noModelsForType') }}
</p>
</div>
</section>
<template v-if="selectedModel">
<section class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('modelSettings.debug.groupInput') }}</h4>
<div v-if="selectedModel.type !== 'ASR'" class="form-item">
<label class="form-label">{{ inputLabel }}</label>
<t-textarea
v-model="input"
:placeholder="inputPlaceholder"
:autosize="{ minRows: 4, maxRows: 8 }"
/>
</div>
<div v-if="selectedModel.type === 'Rerank'" class="form-item">
<label class="form-label">{{ $t('modelSettings.debug.documents') }}</label>
<t-textarea
v-model="documentsText"
:placeholder="$t('modelSettings.debug.documentsPlaceholder')"
:autosize="{ minRows: 4, maxRows: 8 }"
/>
<p class="form-desc">{{ $t('modelSettings.debug.documentsHint') }}</p>
</div>
<div v-if="needsFile" class="form-item">
<label class="form-label">{{ fileLabel }}</label>
<div class="file-picker">
<input
ref="fileInputRef"
class="file-picker__input"
type="file"
:accept="selectedModel.type === 'VLLM' ? 'image/*' : 'audio/*'"
@change="onNativeFileChange"
>
<t-button variant="outline" size="small" @click="fileInputRef?.click()">
<template #icon><t-icon name="upload" /></template>
{{ $t('modelSettings.debug.chooseFile') }}
</t-button>
</div>
<p v-if="file" class="form-desc">{{ file.name }} · {{ formatBytes(file.size) }}</p>
</div>
</section>
<section v-if="isChat" class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('modelSettings.debug.parameters') }}</h4>
<div class="parameter-grid">
<div class="form-item">
<label class="form-label">Temperature</label>
<t-input-number v-model="temperature" :min="0" :max="2" :step="0.1" theme="column" />
</div>
<div class="form-item">
<label class="form-label">Top P</label>
<t-input-number v-model="topP" :min="0.01" :max="1" :step="0.1" theme="column" />
</div>
<div class="form-item">
<label class="form-label">Max Tokens</label>
<t-input-number v-model="maxTokens" :min="1" :max="8192" :step="128" theme="column" />
</div>
</div>
<div class="form-item">
<label class="form-label">{{ $t('modelSettings.debug.systemPrompt') }}</label>
<t-textarea
v-model="systemPrompt"
:placeholder="$t('modelSettings.debug.systemPromptPlaceholder')"
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</div>
<div v-if="supportsThinking" class="form-item">
<label class="form-label">{{ $t('modelSettings.debug.thinking') }}</label>
<div class="switch-field">
<t-switch v-model="thinking" />
<span class="form-desc form-desc--inline">{{ $t('modelSettings.debug.thinkingDesc') }}</span>
</div>
</div>
</section>
<section v-if="result || history.length > 0" class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('modelSettings.debug.groupResult') }}</h4>
<div v-if="history.length > 1" class="form-item">
<label class="form-label">{{ $t('modelSettings.debug.history') }}</label>
<div class="history-list">
<button
v-for="run in history"
:key="run.id"
type="button"
class="history-item"
:class="{ 'history-item--active': result === run.result }"
@click="result = run.result"
>
<span class="history-item__label">{{ run.label }}</span>
<span class="history-item__meta">{{ run.result.elapsed_ms }} ms</span>
</button>
</div>
</div>
<div v-if="result" class="debug-result">
<div class="result-banner" :class="result.ok ? 'result-banner--ok' : 'result-banner--error'">
<t-icon :name="result.ok ? 'check-circle-filled' : 'close-circle-filled'" />
<div class="result-banner__text">
<strong>{{ result.ok ? $t('modelSettings.debug.success') : $t('modelSettings.debug.failed') }}</strong>
<span>{{ result.elapsed_ms }} ms</span>
</div>
</div>
<div v-if="resultMetrics.length > 0" class="metric-list">
<span v-for="metric in resultMetrics" :key="metric.key" class="metric-chip">
{{ metric.label }}: {{ metric.value }}
</span>
</div>
<p v-if="result.error" class="result-error">{{ result.error }}</p>
<t-tabs v-model="resultTab" class="result-tabs">
<t-tab-panel value="response" :label="$t('modelSettings.debug.rawResponse')" />
<t-tab-panel value="request" :label="$t('modelSettings.debug.requestPreview')" />
</t-tabs>
<pre class="json-output">{{ formattedResult }}</pre>
</div>
</section>
</template>
</div>
</SettingDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
import { debugModel, type ModelConfig, type ModelDebugResult } from '@/api/model'
import { modelSupportsThinking } from '@/utils/thinkingControl'
const props = defineProps<{
visible: boolean
models: ModelConfig[]
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
}>()
const { t, te } = useI18n()
const drawerVisible = computed({
get: () => props.visible,
set: value => emit('update:visible', value),
})
type DebugModelType = ModelConfig['type']
const selectedModelType = ref<DebugModelType>('KnowledgeQA')
const selectedModelId = ref('')
const input = ref('')
const documentsText = ref('')
const file = ref<File | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const thinking = ref(false)
const temperature = ref(0.7)
const topP = ref(1)
const maxTokens = ref(1024)
const systemPrompt = ref('')
const running = ref(false)
const result = ref<ModelDebugResult | null>(null)
const resultTab = ref<'response' | 'request'>('response')
const history = ref<Array<{
id: number
label: string
result: ModelDebugResult
}>>([])
let runSequence = 0
const selectedModel = computed(() => props.models.find(model => model.id === selectedModelId.value))
const filteredModels = computed(() => props.models.filter(model => model.type === selectedModelType.value))
const isChat = computed(() => selectedModel.value?.type === 'KnowledgeQA')
const supportsThinking = computed(() => selectedModel.value ? modelSupportsThinking(selectedModel.value) : false)
const needsFile = computed(() => ['VLLM', 'ASR'].includes(selectedModel.value?.type || ''))
const documents = computed(() => documentsText.value.split('\n').map(item => item.trim()).filter(Boolean))
const canRun = computed(() => {
if (!selectedModel.value) return false
if (needsFile.value && !file.value) return false
if (selectedModel.value.type === 'ASR') return true
if (selectedModel.value.type === 'Rerank') return !!input.value.trim() && documents.value.length > 0
return !!input.value.trim()
})
const allModelTypeOptions = computed(() => {
const keys: Record<DebugModelType, { short: string; icon: string }> = {
KnowledgeQA: { short: 'chat', icon: 'chat' },
Embedding: { short: 'embedding', icon: 'chart-bubble' },
Rerank: { short: 'rerank', icon: 'filter-sort' },
VLLM: { short: 'vllm', icon: 'image' },
ASR: { short: 'asr', icon: 'sound' },
}
return (Object.keys(keys) as DebugModelType[]).map(value => ({
value,
label: t(`modelSettings.typeShort.${keys[value].short}`),
icon: keys[value].icon,
}))
})
const modelCount = (type: DebugModelType) => props.models.filter(model => model.type === type).length
const availableModelTypes = computed(() =>
allModelTypeOptions.value.filter(option => modelCount(option.value) > 0),
)
const modelLabel = (model: ModelConfig) => model.display_name?.trim() || model.name
const vendorLabel = (model: ModelConfig) => {
const provider = model.parameters.provider || ''
if (model.source === 'local') return 'Ollama'
if (provider === 'generic') return t('modelSettings.source.custom')
const key = `model.editor.providers.${provider}.label`
return te(key) ? t(key) : provider || model.source
}
const inputLabel = computed(() => {
if (selectedModel.value?.type === 'Embedding') return t('modelSettings.debug.embeddingInput')
if (selectedModel.value?.type === 'VLLM') return t('modelSettings.debug.vlmPrompt')
if (selectedModel.value?.type === 'Rerank') return t('modelSettings.debug.query')
return t('modelSettings.debug.query')
})
const inputPlaceholder = computed(() => {
if (selectedModel.value?.type === 'Embedding') return t('modelSettings.debug.embeddingPlaceholder')
if (selectedModel.value?.type === 'VLLM') return t('modelSettings.debug.vlmPromptPlaceholder')
return t('modelSettings.debug.queryPlaceholder')
})
const fileLabel = computed(() =>
selectedModel.value?.type === 'VLLM'
? t('modelSettings.debug.imageFile')
: t('modelSettings.debug.audioFile'),
)
const formattedResult = computed(() => {
if (!result.value) return ''
const value = resultTab.value === 'response'
? result.value.raw_response
: result.value.request
return JSON.stringify(value, null, 2)
})
const OBSERVATION_LABELS: Record<string, string> = {
dimension: 'modelSettings.debug.metrics.dimension',
result_count: 'modelSettings.debug.metrics.resultCount',
answer_characters: 'modelSettings.debug.metrics.answerChars',
reasoning_characters: 'modelSettings.debug.metrics.reasoningChars',
reasoning_returned: 'modelSettings.debug.metrics.reasoningReturned',
text_characters: 'modelSettings.debug.metrics.textChars',
segment_count: 'modelSettings.debug.metrics.segmentCount',
}
const resultMetrics = computed(() => {
if (!result.value?.observations) return []
const obs = result.value.observations
const keys = Object.keys(OBSERVATION_LABELS).filter(key => obs[key] !== undefined && obs[key] !== null)
return keys.map(key => ({
key,
label: t(OBSERVATION_LABELS[key]),
value: formatMetricValue(key, obs[key]),
}))
})
const formatMetricValue = (key: string, value: unknown) => {
if (typeof value === 'boolean') {
return value ? t('common.yes') : t('common.no')
}
return String(value)
}
const ensureDefaultSelection = () => {
const types = availableModelTypes.value
if (types.length === 0) {
selectedModelId.value = ''
return
}
if (!types.some(option => option.value === selectedModelType.value)) {
selectedModelType.value = types[0].value
}
const models = filteredModels.value
if (!models.some(model => model.id === selectedModelId.value)) {
selectedModelId.value = models[0]?.id || ''
}
}
watch(() => props.visible, visible => {
if (visible) ensureDefaultSelection()
})
watch(availableModelTypes, () => {
if (props.visible) ensureDefaultSelection()
})
watch(() => selectedModel.value?.id, () => {
if (!supportsThinking.value) thinking.value = false
})
watch(() => selectedModel.value?.type, () => {
file.value = null
result.value = null
history.value = []
resultTab.value = 'response'
})
const resetResult = () => {
result.value = null
history.value = []
resultTab.value = 'response'
}
const selectModelType = (type: DebugModelType) => {
if (selectedModelType.value === type) return
selectedModelType.value = type
selectedModelId.value = filteredModels.value[0]?.id || ''
input.value = ''
documentsText.value = ''
file.value = null
resetResult()
}
const onNativeFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
file.value = target.files?.[0] || null
resetResult()
}
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const historyLabel = (thinkingValue: boolean) => {
if (supportsThinking.value) {
return thinkingValue ? t('modelSettings.debug.thinkOn') : t('modelSettings.debug.thinkOff')
}
return t('modelSettings.debug.runLabel', { n: runSequence })
}
const runDebug = async () => {
if (!selectedModel.value?.id || !canRun.value || running.value) return
running.value = true
try {
const thinkingValue = supportsThinking.value ? thinking.value : false
const nextResult = await debugModel(selectedModel.value.id, {
input: input.value.trim(),
documents: documents.value,
file: file.value,
options: isChat.value ? {
system_prompt: systemPrompt.value.trim() || undefined,
temperature: temperature.value,
top_p: topP.value,
max_tokens: maxTokens.value,
thinking: thinkingValue,
} : {},
})
result.value = nextResult
history.value.unshift({
id: ++runSequence,
label: historyLabel(thinkingValue),
result: nextResult,
})
history.value = history.value.slice(0, 6)
resultTab.value = 'response'
} catch (error: any) {
MessagePlugin.error(error?.message || t('modelSettings.debug.requestFailed'))
} finally {
running.value = false
}
}
const copyResult = async () => {
if (!result.value) return
try {
await navigator.clipboard.writeText(JSON.stringify(result.value, null, 2))
MessagePlugin.success(t('common.copied'))
} catch {
MessagePlugin.error(t('common.copyFailed'))
}
}
</script>
<style scoped lang="less">
.form-item {
margin-bottom: 0;
}
.form-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--td-text-color-primary);
line-height: 1.4;
}
.form-desc {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--td-text-color-placeholder);
&--inline {
margin: 0;
}
}
.model-type-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.model-type-option {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
min-height: 32px;
border: 1px solid var(--td-component-stroke);
border-radius: 8px;
background: var(--td-bg-color-container);
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 1.4;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
&__icon {
font-size: 15px;
flex-shrink: 0;
}
&__label {
white-space: nowrap;
}
&:hover:not(.is-active) {
border-color: var(--td-brand-color-3, var(--td-brand-color));
color: var(--td-text-color-primary);
}
&.is-active {
border-color: var(--td-brand-color);
background: color-mix(in srgb, var(--td-brand-color) 10%, transparent);
color: var(--td-brand-color);
font-weight: 500;
}
&:focus-visible {
outline: 2px solid var(--td-brand-color);
outline-offset: 2px;
}
}
.model-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
&__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__meta {
flex-shrink: 0;
color: var(--td-text-color-placeholder);
font-size: 12px;
}
}
.parameter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
:deep(.t-input-number) {
width: 100%;
}
}
.switch-field {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
}
.file-picker {
position: relative;
&__input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
}
.history-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.history-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
background: var(--td-bg-color-container);
color: var(--td-text-color-secondary);
cursor: pointer;
font: inherit;
font-size: 12px;
&__label {
color: var(--td-text-color-primary);
font-weight: 500;
}
&__meta {
color: var(--td-text-color-placeholder);
}
&:hover,
&--active {
border-color: var(--td-brand-color);
background: color-mix(in srgb, var(--td-brand-color) 6%, var(--td-bg-color-container));
}
}
.result-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
&--ok {
background: var(--td-success-color-light);
color: var(--td-success-color);
.result-banner__text strong {
color: var(--td-text-color-primary);
}
}
&--error {
background: var(--td-error-color-light);
color: var(--td-error-color);
.result-banner__text strong {
color: var(--td-text-color-primary);
}
}
&__text {
display: flex;
align-items: baseline;
gap: 8px;
span {
color: var(--td-text-color-placeholder);
font-size: 12px;
}
}
}
.metric-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.metric-chip {
padding: 2px 8px;
border-radius: 4px;
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-secondary);
font-size: 12px;
}
.result-error {
margin: 10px 0 0;
color: var(--td-error-color);
font-size: 13px;
white-space: pre-wrap;
}
.result-tabs {
margin-top: 12px;
:deep(.t-tabs__content) {
display: none;
}
}
.json-output {
overflow: auto;
max-height: 420px;
min-height: 140px;
margin: 8px 0 0;
padding: 12px 14px;
border: 1px solid var(--td-component-stroke);
border-radius: 8px;
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-primary);
font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 640px) {
.parameter-grid {
grid-template-columns: 1fr;
}
}
</style>
+49
View File
@@ -4237,6 +4237,7 @@ export default {
},
actions: {
addModel: 'Add Model',
debugModel: 'Model Test',
setDefault: 'Set as Default'
},
source: {
@@ -4298,6 +4299,54 @@ export default {
},
builtinTag: 'Built-in',
confirmDelete: 'Delete model "{name}"?',
debug: {
title: 'Model Test',
description: 'Send a real request to a saved model and inspect the response',
groupModel: 'Select model',
groupInput: 'Test input',
groupResult: 'Result',
modelType: 'Model type',
model: 'Model',
modelPlaceholder: 'Select a model to test',
noModelsForType: 'No saved models of this type',
query: 'Input',
queryPlaceholder: 'Enter the text to send to the model',
embeddingInput: 'Text to embed',
embeddingPlaceholder: 'Enter text to generate an embedding',
vlmPrompt: 'Image prompt',
vlmPromptPlaceholder: 'For example: Describe this image in detail',
documents: 'Candidate documents',
documentsPlaceholder: 'Enter one candidate document per line',
documentsHint: 'Each non-empty line is sent as a separate document to the ReRank model',
imageFile: 'Image file',
audioFile: 'Audio file',
chooseFile: 'Choose file',
parameters: 'Request parameters',
thinking: 'Thinking mode',
thinkingDesc: 'Only applies to models that support thinking',
systemPrompt: 'System Prompt',
systemPromptPlaceholder: 'Optional system prompt',
run: 'Run test',
copyResult: 'Copy result',
history: 'History',
thinkOn: 'Thinking on',
thinkOff: 'Thinking off',
runLabel: 'Run #{n}',
success: 'Request succeeded',
failed: 'Request failed',
rawResponse: 'Response',
requestPreview: 'Request preview',
requestFailed: 'Model test request failed',
metrics: {
dimension: 'Dimensions',
resultCount: 'Result count',
answerChars: 'Answer chars',
reasoningChars: 'Reasoning chars',
reasoningReturned: 'Reasoning returned',
textChars: 'Transcript chars',
segmentCount: 'Segment count',
},
},
},
// New: Ollama Settings
ollamaSettings: {
+49
View File
@@ -4233,6 +4233,7 @@ export default {
},
actions: {
addModel: "모델 추가",
debugModel: "모델 테스트",
setDefault: "기본값으로 설정",
},
source: {
@@ -4294,6 +4295,54 @@ export default {
},
builtinTag: "기본제공",
confirmDelete: '모델 "{name}"을(를) 삭제하시겠습니까?',
debug: {
title: "모델 테스트",
description: "구성된 모델에 실제 요청을 보내고 응답과 소요 시간을 확인합니다",
groupModel: "모델 선택",
groupInput: "테스트 입력",
groupResult: "실행 결과",
modelType: "모델 유형",
model: "모델",
modelPlaceholder: "테스트할 모델을 선택하세요",
noModelsForType: "이 유형의 저장된 모델이 없습니다",
query: "입력 내용",
queryPlaceholder: "모델에 보낼 내용을 입력하세요",
embeddingInput: "벡터화할 텍스트",
embeddingPlaceholder: "Embedding을 생성할 텍스트를 입력하세요",
vlmPrompt: "이미지 프롬프트",
vlmPromptPlaceholder: "예: 이 이미지를 자세히 설명해 주세요",
documents: "후보 문서",
documentsPlaceholder: "후보 문서를 한 줄에 하나씩 입력하세요",
documentsHint: "비어 있지 않은 각 줄은 ReRank 모델에 별도 문서로 전송됩니다",
imageFile: "이미지 파일",
audioFile: "오디오 파일",
chooseFile: "파일 선택",
parameters: "요청 매개변수",
thinking: "사고 모드",
thinkingDesc: "사고 모드를 지원하는 모델에만 적용됩니다",
systemPrompt: "System Prompt",
systemPromptPlaceholder: "선택 사항, 시스템 프롬프트 입력",
run: "테스트 실행",
copyResult: "결과 복사",
history: "기록",
thinkOn: "사고 켜짐",
thinkOff: "사고 꺼짐",
runLabel: "{n}번째 실행",
success: "호출 성공",
failed: "호출 실패",
rawResponse: "응답 내용",
requestPreview: "요청 미리보기",
requestFailed: "모델 테스트 요청 실패",
metrics: {
dimension: "벡터 차원",
resultCount: "결과 수",
answerChars: "답변 문자 수",
reasoningChars: "추론 문자 수",
reasoningReturned: "추론 내용 반환",
textChars: "전사 문자 수",
segmentCount: "세그먼트 수",
},
},
},
// Ollama 설정
ollamaSettings: {
+50 -1
View File
@@ -3888,6 +3888,7 @@ export default {
},
actions: {
addModel: 'Добавить модель',
debugModel: 'Тест модели',
setDefault: 'Сделать по умолчанию'
},
chat: {
@@ -3948,7 +3949,55 @@ export default {
viewGuide: 'Посмотреть руководство по управлению встроенными моделями'
},
builtinTag: 'Встроенная',
confirmDelete: 'Удалить модель «{name}»?'
confirmDelete: 'Удалить модель «{name}»?',
debug: {
title: 'Тест модели',
description: 'Отправьте реальный запрос к настроенной модели и проверьте ответ и время выполнения',
groupModel: 'Выбор модели',
groupInput: 'Тестовый ввод',
groupResult: 'Результат',
modelType: 'Тип модели',
model: 'Модель',
modelPlaceholder: 'Выберите модель для теста',
noModelsForType: 'Нет сохранённых моделей этого типа',
query: 'Ввод',
queryPlaceholder: 'Введите текст для отправки модели',
embeddingInput: 'Текст для векторизации',
embeddingPlaceholder: 'Введите текст для создания embedding',
vlmPrompt: 'Промпт для изображения',
vlmPromptPlaceholder: 'Например: подробно опишите это изображение',
documents: 'Кандидаты документов',
documentsPlaceholder: 'Введите один документ на строку',
documentsHint: 'Каждая непустая строка отправляется в модель ReRank как отдельный документ',
imageFile: 'Файл изображения',
audioFile: 'Аудиофайл',
chooseFile: 'Выбрать файл',
parameters: 'Параметры запроса',
thinking: 'Режим размышления',
thinkingDesc: 'Применяется только к моделям с поддержкой размышления',
systemPrompt: 'System Prompt',
systemPromptPlaceholder: 'Необязательно: системный промпт',
run: 'Запустить тест',
copyResult: 'Копировать результат',
history: 'История',
thinkOn: 'Размышление вкл.',
thinkOff: 'Размышление выкл.',
runLabel: 'Запуск №{n}',
success: 'Запрос выполнен',
failed: 'Запрос не выполнен',
rawResponse: 'Ответ',
requestPreview: 'Просмотр запроса',
requestFailed: 'Не удалось выполнить тест модели',
metrics: {
dimension: 'Размерность вектора',
resultCount: 'Количество результатов',
answerChars: 'Символов в ответе',
reasoningChars: 'Символов рассуждения',
reasoningReturned: 'Рассуждение возвращено',
textChars: 'Символов транскрипции',
segmentCount: 'Количество сегментов',
},
},
},
ollamaSettings: {
title: 'Настройки Ollama',
+49
View File
@@ -4237,6 +4237,7 @@ export default {
},
actions: {
addModel: "添加模型",
debugModel: "模型测试",
setDefault: "设为默认",
},
source: {
@@ -4298,6 +4299,54 @@ export default {
},
builtinTag: "内置",
confirmDelete: "确定删除模型「{name}」吗?",
debug: {
title: "模型测试",
description: "向已配置的模型发送真实请求,查看响应与耗时",
groupModel: "选择模型",
groupInput: "测试输入",
groupResult: "运行结果",
modelType: "模型类型",
model: "模型",
modelPlaceholder: "请选择要测试的模型",
noModelsForType: "当前类型暂无已添加模型",
query: "输入内容",
queryPlaceholder: "输入要发送给模型的内容",
embeddingInput: "待向量化文本",
embeddingPlaceholder: "输入要生成 Embedding 的文本",
vlmPrompt: "图片提示词",
vlmPromptPlaceholder: "例如:请详细描述这张图片",
documents: "候选文档",
documentsPlaceholder: "每行输入一个候选文档",
documentsHint: "每个非空行会作为一个独立文档发送给 ReRank 模型",
imageFile: "图片文件",
audioFile: "音频文件",
chooseFile: "选择文件",
parameters: "请求参数",
thinking: "思考模式",
thinkingDesc: "仅对支持思考模式的模型生效",
systemPrompt: "System Prompt",
systemPromptPlaceholder: "可选,输入系统提示词",
run: "运行测试",
copyResult: "复制结果",
history: "历史记录",
thinkOn: "思考开启",
thinkOff: "思考关闭",
runLabel: "第 {n} 次运行",
success: "调用成功",
failed: "调用失败",
rawResponse: "响应内容",
requestPreview: "请求预览",
requestFailed: "模型测试请求失败",
metrics: {
dimension: "向量维度",
resultCount: "结果数量",
answerChars: "回答字符数",
reasoningChars: "推理字符数",
reasoningReturned: "返回推理内容",
textChars: "转写字符数",
segmentCount: "分段数量",
},
},
},
// 新增:Ollama 设置
ollamaSettings: {
+9 -2
View File
@@ -257,13 +257,20 @@ export async function getDown(url: string) {
return res
}
export function postUpload(url: string, data = {}, onUploadProgress?: (progressEvent: any) => void) {
export function postUpload(
url: string,
data = {},
onUploadProgress?: (progressEvent: any) => void,
config: any = {},
) {
return instance.post(url, data, {
...config,
headers: {
"Content-Type": "multipart/form-data",
"X-Request-ID": `${generateRandomString(12)}`,
...(config.headers || {}),
},
onUploadProgress,
onUploadProgress: onUploadProgress || config.onUploadProgress,
});
}
+18
View File
@@ -69,3 +69,21 @@ export function resolveThinkingControl(
}
return defaultThinkingControl(provider, modelName)
}
/** Whether the debug drawer should expose Think on/off for this saved model. */
export function modelSupportsThinking(model: {
type: string
source: string
name: string
parameters: {
provider?: string
extra_config?: { thinking_control?: string }
}
}): boolean {
if (model.type !== 'KnowledgeQA' || model.source !== 'remote') return false
return resolveThinkingControl(
model.parameters.extra_config?.thinking_control,
model.parameters.provider || '',
model.name || '',
) !== 'none'
}
+49 -3
View File
@@ -1,8 +1,24 @@
<template>
<div class="model-settings">
<div class="section-header">
<h2>{{ $t('modelSettings.title') }}</h2>
<p class="section-description">{{ $t('modelSettings.description') }}</p>
<div class="section-header__top">
<div>
<h2>{{ $t('modelSettings.title') }}</h2>
<p class="section-description">{{ $t('modelSettings.description') }}</p>
</div>
<t-button
v-if="authStore.hasRole('admin')"
type="button"
theme="primary"
variant="text"
size="medium"
class="model-test-trigger"
@click="showDebugDrawer = true"
>
<template #icon><play-circle-icon /></template>
{{ $t('modelSettings.actions.debugModel') }}
</t-button>
</div>
<div class="builtin-models-hint" role="note">
<p class="builtin-hint-label">{{ $t('modelSettings.builtinModels.title') }}</p>
@@ -114,6 +130,7 @@
<!-- 模型编辑器抽屉 -->
<ModelEditorDialog v-model:visible="showDialog" :model-type="currentModelType" :model-data="editingModel"
@confirm="handleModelSave" />
<ModelDebugDrawer v-model:visible="showDebugDrawer" :models="allModels" />
</div>
</template>
@@ -121,9 +138,10 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { AddIcon } from 'tdesign-icons-vue-next'
import { AddIcon, PlayCircleIcon } from 'tdesign-icons-vue-next'
import { useI18n } from 'vue-i18n'
import ModelEditorDialog from '@/components/ModelEditorDialog.vue'
import ModelDebugDrawer from '@/components/ModelDebugDrawer.vue'
import { listModels, createModel, updateModel as updateModelAPI, deleteModel as deleteModelAPI, type ModelConfig } from '@/api/model'
import { useAuthStore } from '@/stores/auth'
@@ -133,6 +151,7 @@ type ModelType = 'chat' | 'embedding' | 'rerank' | 'vllm' | 'asr'
type FilterType = 'all' | ModelType
const showDialog = ref(false)
const showDebugDrawer = ref(false)
const currentModelType = ref<ModelType>('chat')
const editingModel = ref<any>(null)
const loading = ref(true)
@@ -585,6 +604,33 @@ onMounted(() => {
}
}
.section-header__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.model-test-trigger {
--td-bg-color-container-hover: transparent;
flex-shrink: 0;
padding-left: 0;
padding-right: 0;
font-weight: 600;
&:hover,
&:focus,
&.t-is-active,
&:active {
background-color: transparent !important;
color: var(--td-brand-color-hover);
}
&:active {
color: var(--td-brand-color-active);
}
}
.builtin-models-hint {
margin-top: 12px;
padding: 10px 12px;
+323
View File
@@ -1,12 +1,18 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
@@ -199,6 +205,323 @@ func (h *ModelHandler) ListModels(c *gin.Context) {
})
}
const (
modelDebugMaxInputBytes = 64 * 1024
modelDebugMaxFileBytes = 20 * 1024 * 1024
)
// ModelDebugOptions contains the cross-provider parameters exposed by the
// model debugger. Pointer fields preserve explicit zero/false values.
type ModelDebugOptions struct {
SystemPrompt string `json:"system_prompt,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
Thinking *bool `json:"thinking,omitempty"`
}
func parseModelDebugOptions(raw string) (ModelDebugOptions, error) {
var opts ModelDebugOptions
if strings.TrimSpace(raw) == "" {
return opts, nil
}
if err := json.Unmarshal([]byte(raw), &opts); err != nil {
return opts, fmt.Errorf("invalid options: %w", err)
}
if opts.MaxTokens != nil && (*opts.MaxTokens < 1 || *opts.MaxTokens > 8192) {
return opts, fmt.Errorf("max_tokens must be between 1 and 8192")
}
if opts.Temperature != nil && (*opts.Temperature < 0 || *opts.Temperature > 2) {
return opts, fmt.Errorf("temperature must be between 0 and 2")
}
if opts.TopP != nil && (*opts.TopP <= 0 || *opts.TopP > 1) {
return opts, fmt.Errorf("top_p must be greater than 0 and at most 1")
}
return opts, nil
}
func redactedDebugConfig(config map[string]string) map[string]string {
if len(config) == 0 {
return nil
}
out := make(map[string]string, len(config))
for key, value := range config {
lower := strings.ToLower(key)
if strings.Contains(lower, "secret") ||
strings.Contains(lower, "token") ||
strings.Contains(lower, "password") ||
strings.Contains(lower, "api_key") ||
strings.Contains(lower, "apikey") ||
strings.Contains(lower, "authorization") {
out[key] = "[REDACTED]"
continue
}
out[key] = value
}
return out
}
func modelDebugRequestPreview(model *types.Model, input string, documents []string, opts ModelDebugOptions, fileName string, fileSize int64) gin.H {
preview := gin.H{
"model_id": model.ID,
"model_name": model.Name,
"model_type": model.Type,
"source": model.Source,
"provider": model.Parameters.Provider,
"input": input,
"options": opts,
}
if len(documents) > 0 {
preview["documents"] = documents
}
if fileName != "" {
preview["file"] = gin.H{"name": fileName, "size": fileSize}
}
if model.Parameters.ExtraConfig != nil {
preview["model_extra_config"] = redactedDebugConfig(model.Parameters.ExtraConfig)
}
if len(model.Parameters.CustomHeaders) > 0 {
headerNames := make([]string, 0, len(model.Parameters.CustomHeaders))
for name := range model.Parameters.CustomHeaders {
headerNames = append(headerNames, name)
}
preview["custom_header_names"] = headerNames
}
return preview
}
func writeModelDebugResult(c *gin.Context, started time.Time, request gin.H, response any, callErr error, observations gin.H) {
data := gin.H{
"ok": callErr == nil,
"elapsed_ms": time.Since(started).Milliseconds(),
"request": request,
"raw_response": response,
"observations": observations,
}
if callErr != nil {
data["error"] = callErr.Error()
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
}
type modelDebugChatStreamResponse struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []types.LLMToolCall `json:"tool_calls,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
Usage *types.TokenUsage `json:"usage,omitempty"`
StreamEvents []types.StreamResponse `json:"stream_events"`
}
func consumeModelDebugChatStream(stream <-chan types.StreamResponse) (*modelDebugChatStreamResponse, error) {
result := &modelDebugChatStreamResponse{
StreamEvents: make([]types.StreamResponse, 0),
}
for event := range stream {
result.StreamEvents = append(result.StreamEvents, event)
switch event.ResponseType {
case types.ResponseTypeThinking:
result.ReasoningContent += event.Content
case types.ResponseTypeAnswer:
result.Content += event.Content
if len(event.ToolCalls) > 0 {
result.ToolCalls = event.ToolCalls
}
if event.FinishReason != "" {
result.FinishReason = event.FinishReason
}
if event.Usage != nil {
result.Usage = event.Usage
}
case types.ResponseTypeToolCall:
if len(event.ToolCalls) > 0 {
result.ToolCalls = event.ToolCalls
}
case types.ResponseTypeError:
return result, fmt.Errorf("%s", event.Content)
}
}
return result, nil
}
// DebugModel executes a saved model through the same service constructors used
// by production calls and returns the complete normalized response. Credentials
// stay server-side; the request preview contains only non-secret fields.
func (h *ModelHandler) DebugModel(c *gin.Context) {
ctx := c.Request.Context()
started := time.Now()
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
c.Error(errors.NewBadRequestError("Model ID cannot be empty"))
return
}
model, err := h.service.GetModelByID(ctx, id)
if err != nil {
if err == service.ErrModelNotFound {
c.Error(errors.NewNotFoundError("Model not found"))
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
input := c.PostForm("input")
if len(input) > modelDebugMaxInputBytes {
c.Error(errors.NewBadRequestError("input is too long"))
return
}
opts, err := parseModelDebugOptions(c.PostForm("options"))
if err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
var documents []string
if rawDocuments := c.PostForm("documents"); strings.TrimSpace(rawDocuments) != "" {
if err := json.Unmarshal([]byte(rawDocuments), &documents); err != nil {
c.Error(errors.NewBadRequestError("documents must be a JSON string array"))
return
}
if len(documents) > 100 {
c.Error(errors.NewBadRequestError("documents cannot exceed 100 items"))
return
}
}
var (
fileBytes []byte
fileName string
fileSize int64
)
if file, header, fileErr := c.Request.FormFile("file"); fileErr == nil {
defer file.Close()
fileName = header.Filename
fileSize = header.Size
if fileSize > modelDebugMaxFileBytes {
c.Error(errors.NewBadRequestError("file cannot exceed 20 MB"))
return
}
fileBytes, err = io.ReadAll(io.LimitReader(file, modelDebugMaxFileBytes+1))
if err != nil {
c.Error(errors.NewBadRequestError("failed to read uploaded file"))
return
}
if len(fileBytes) > modelDebugMaxFileBytes {
c.Error(errors.NewBadRequestError("file cannot exceed 20 MB"))
return
}
fileSize = int64(len(fileBytes))
}
requestPreview := modelDebugRequestPreview(model, input, documents, opts, fileName, fileSize)
observations := gin.H{}
switch model.Type {
case types.ModelTypeKnowledgeQA:
if strings.TrimSpace(input) == "" {
c.Error(errors.NewBadRequestError("query cannot be empty"))
return
}
instance, callErr := h.service.GetChatModel(ctx, id)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
messages := make([]chat.Message, 0, 2)
if strings.TrimSpace(opts.SystemPrompt) != "" {
messages = append(messages, chat.Message{Role: "system", Content: opts.SystemPrompt})
}
messages = append(messages, chat.Message{Role: "user", Content: input})
chatOpts := &chat.ChatOptions{}
if opts.Temperature != nil {
chatOpts.Temperature = *opts.Temperature
}
if opts.TopP != nil {
chatOpts.TopP = *opts.TopP
}
if opts.MaxTokens != nil {
chatOpts.MaxTokens = *opts.MaxTokens
}
chatOpts.Thinking = opts.Thinking
chatConfig := chat.ConfigFromModel(model, "", "")
thinkingControl := chat.EffectiveThinkingControl(chatConfig)
observations["stream"] = true
observations["requested_thinking"] = opts.Thinking != nil && *opts.Thinking
observations["thinking_control"] = thinkingControl
observations["thinking_parameter_sent"] = opts.Thinking != nil && thinkingControl != "none"
stream, callErr := instance.ChatStream(ctx, messages, chatOpts)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
resp, callErr := consumeModelDebugChatStream(stream)
if resp != nil {
observations["reasoning_returned"] = strings.TrimSpace(resp.ReasoningContent) != ""
observations["reasoning_characters"] = len([]rune(resp.ReasoningContent))
observations["answer_characters"] = len([]rune(resp.Content))
}
writeModelDebugResult(c, started, requestPreview, resp, callErr, observations)
case types.ModelTypeEmbedding:
if strings.TrimSpace(input) == "" {
c.Error(errors.NewBadRequestError("input cannot be empty"))
return
}
instance, callErr := h.service.GetEmbeddingModel(ctx, id)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
vector, callErr := instance.Embed(ctx, input)
observations["dimension"] = len(vector)
writeModelDebugResult(c, started, requestPreview, vector, callErr, observations)
case types.ModelTypeRerank:
if strings.TrimSpace(input) == "" || len(documents) == 0 {
c.Error(errors.NewBadRequestError("query and documents cannot be empty"))
return
}
instance, callErr := h.service.GetRerankModel(ctx, id)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
results, callErr := instance.Rerank(ctx, input, documents)
observations["result_count"] = len(results)
writeModelDebugResult(c, started, requestPreview, results, callErr, observations)
case types.ModelTypeVLLM:
if len(fileBytes) == 0 {
c.Error(errors.NewBadRequestError("image file is required"))
return
}
instance, callErr := h.service.GetVLMModel(ctx, id)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
result, callErr := instance.Predict(ctx, [][]byte{fileBytes}, input)
observations["answer_characters"] = len([]rune(result))
writeModelDebugResult(c, started, requestPreview, result, callErr, observations)
case types.ModelTypeASR:
if len(fileBytes) == 0 {
c.Error(errors.NewBadRequestError("audio file is required"))
return
}
instance, callErr := h.service.GetASRModel(ctx, id)
if callErr != nil {
writeModelDebugResult(c, started, requestPreview, nil, callErr, observations)
return
}
result, callErr := instance.Transcribe(ctx, fileBytes, fileName)
if result != nil {
observations["text_characters"] = len([]rune(result.Text))
observations["segment_count"] = len(result.Segments)
}
writeModelDebugResult(c, started, requestPreview, result, callErr, observations)
default:
c.Error(errors.NewBadRequestError("unsupported model type"))
}
}
// UpdateModelRequest defines the structure for model update requests
// Contains fields that can be updated for an existing model
type UpdateModelRequest struct {
+52
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -18,3 +19,54 @@ func TestModelUpdateRequestDisplayNamePresence(t *testing.T) {
require.NotNil(t, cleared.DisplayName)
assert.Equal(t, "", *cleared.DisplayName)
}
func TestParseModelDebugOptionsPreservesExplicitThinkingFalse(t *testing.T) {
opts, err := parseModelDebugOptions(`{"thinking":false,"temperature":0,"max_tokens":256}`)
require.NoError(t, err)
require.NotNil(t, opts.Thinking)
assert.False(t, *opts.Thinking)
require.NotNil(t, opts.Temperature)
assert.Zero(t, *opts.Temperature)
require.NotNil(t, opts.MaxTokens)
assert.Equal(t, 256, *opts.MaxTokens)
}
func TestParseModelDebugOptionsRejectsOutOfRangeValues(t *testing.T) {
_, err := parseModelDebugOptions(`{"top_p":0}`)
require.ErrorContains(t, err, "top_p")
}
func TestRedactedDebugConfig(t *testing.T) {
got := redactedDebugConfig(map[string]string{
"thinking_control": "enable_thinking",
"secret_key": "do-not-leak",
"access_token": "do-not-leak-either",
})
assert.Equal(t, "enable_thinking", got["thinking_control"])
assert.Equal(t, "[REDACTED]", got["secret_key"])
assert.Equal(t, "[REDACTED]", got["access_token"])
}
func TestConsumeModelDebugChatStream(t *testing.T) {
stream := make(chan types.StreamResponse, 5)
stream <- types.StreamResponse{ResponseType: types.ResponseTypeThinking, Content: "reason "}
stream <- types.StreamResponse{ResponseType: types.ResponseTypeThinking, Content: "more", Done: true}
stream <- types.StreamResponse{ResponseType: types.ResponseTypeAnswer, Content: "answer "}
stream <- types.StreamResponse{ResponseType: types.ResponseTypeAnswer, Content: "done"}
stream <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Done: true,
FinishReason: "stop",
Usage: &types.TokenUsage{PromptTokens: 3, CompletionTokens: 4, TotalTokens: 7},
}
close(stream)
got, err := consumeModelDebugChatStream(stream)
require.NoError(t, err)
assert.Equal(t, "reason more", got.ReasoningContent)
assert.Equal(t, "answer done", got.Content)
assert.Equal(t, "stop", got.FinishReason)
require.NotNil(t, got.Usage)
assert.Equal(t, 7, got.Usage.TotalTokens)
assert.Len(t, got.StreamEvents, 5)
}
+32
View File
@@ -3,6 +3,7 @@ package chat
import (
"strings"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/sashabaranov/go-openai"
)
@@ -138,3 +139,34 @@ func parseThinkingOverride(extraConfig map[string]string) ThinkingStrategy {
return chatTemplateKwargs{}
}
}
// EffectiveThinkingControl reports the provider field that will carry
// ChatOptions.Thinking. It intentionally shares the same adapter/override
// resolution as the real request path so diagnostics do not guess from the
// frontend selection.
func EffectiveThinkingControl(config *ChatConfig) string {
if config == nil {
return "none"
}
if override := parseThinkingOverride(config.ExtraConfig); override != nil {
return thinkingStrategyName(override)
}
providerName := provider.ProviderName(config.Provider)
if providerName == "" {
providerName = provider.DetectProvider(config.BaseURL)
}
return thinkingStrategyName(resolveProvider(providerName, config.ModelName).Thinking())
}
func thinkingStrategyName(strategy ThinkingStrategy) string {
switch strategy.(type) {
case enableThinking:
return "enable_thinking"
case thinkingTypeField:
return "thinking_type"
case chatTemplateKwargs:
return "chat_template_kwargs"
default:
return "none"
}
}
+17
View File
@@ -125,3 +125,20 @@ func TestParseThinkingOverride(t *testing.T) {
assert.Nil(t, parseThinkingOverride(map[string]string{}))
assert.Nil(t, parseThinkingOverride(map[string]string{ExtraConfigThinkingControl: ""}))
}
func TestEffectiveThinkingControl(t *testing.T) {
assert.Equal(t, "enable_thinking", EffectiveThinkingControl(&ChatConfig{
Provider: "aliyun",
ModelName: "qwen3-32b",
}))
assert.Equal(t, "chat_template_kwargs", EffectiveThinkingControl(&ChatConfig{
Provider: "generic",
ModelName: "qwen3",
ExtraConfig: map[string]string{ExtraConfigThinkingControl: "chat_template_kwargs"},
}))
assert.Equal(t, "none", EffectiveThinkingControl(&ChatConfig{
Provider: "generic",
ModelName: "qwen3",
ExtraConfig: map[string]string{ExtraConfigThinkingControl: "none"},
}))
}
+2
View File
@@ -635,6 +635,8 @@ func RegisterModelRoutes(
models.POST("", g.Admin(), handler.CreateModel)
// 获取模型列表 — Viewer+
models.GET("", g.Viewer(), handler.ListModels)
// 调试已保存模型会发起真实上游调用并产生费用 — Admin+
models.POST("/:id/debug", g.Admin(), handler.DebugModel)
// 获取单个模型 — Viewer+
models.GET("/:id", g.Viewer(), handler.GetModel)
// 更新模型 — Admin+