372 lines
22 KiB
TypeScript
372 lines
22 KiB
TypeScript
/** Local custom models. Credentials stay in the main process; legacy config is read only. */
|
|
import { app, safeStorage } from 'electron'
|
|
import { promises as fs } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { randomUUID } from 'node:crypto'
|
|
import type { CustomModelInput, CustomModelProfile, ModelRegistryView, ModelTestResult, ResolvedModel, ModelDiscoveryInput, ModelDiscoveryResult, ModelEndpoint } from '@shared/models'
|
|
import { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
|
|
import { apiProtocol, endpointCandidates, endpointUrl, inferModelApi, modelEndpoint, modelHeaders, normalizeModelAddress, openAICompletionTokenField } from '@shared/model-endpoint'
|
|
import { cachedConnection, cachedGeneration, discoverModelConnection, modelFetch, readModelJson, rememberGeneration } from './model-connection'
|
|
import { modelMetadata } from './model-metadata'
|
|
import { resolveModelTokenLimits } from '@shared/model-token-limits'
|
|
import { getGatewayConfig } from './config'
|
|
|
|
interface StoredModel extends Omit<CustomModelProfile, 'hasApiKey' | 'keyStorage'> {
|
|
apiKeyEnc?: string
|
|
apiKeyPlain?: string
|
|
}
|
|
interface StoredRegistry {
|
|
version: 1
|
|
models: StoredModel[]
|
|
selectedModelId: string | null
|
|
}
|
|
|
|
let cache: StoredRegistry | null = null
|
|
let loading: Promise<StoredRegistry> | null = null
|
|
let writes: Promise<unknown> = Promise.resolve()
|
|
|
|
function filePath(): string { return join(app.getPath('userData'), 'models.json') }
|
|
|
|
function readKey(model: StoredModel): { key: string; storage: CustomModelProfile['keyStorage'] } {
|
|
if (model.apiKeyEnc) {
|
|
try { return { key: safeStorage.decryptString(Buffer.from(model.apiKeyEnc, 'base64')), storage: 'encrypted' } }
|
|
catch { return { key: '', storage: 'none' } }
|
|
}
|
|
return model.apiKeyPlain ? { key: model.apiKeyPlain, storage: 'plaintext' } : { key: '', storage: 'none' }
|
|
}
|
|
|
|
function writeKey(model: StoredModel, key: string): void {
|
|
delete model.apiKeyEnc
|
|
delete model.apiKeyPlain
|
|
if (!key) return
|
|
try {
|
|
if (safeStorage.isEncryptionAvailable()) model.apiKeyEnc = safeStorage.encryptString(key).toString('base64')
|
|
else model.apiKeyPlain = key
|
|
} catch { throw new Error('无法安全保存 API Key,请检查系统加密服务') }
|
|
}
|
|
|
|
function viewOf(model: StoredModel): CustomModelProfile {
|
|
const { apiKeyEnc: _encrypted, apiKeyPlain: _plain, ...profile } = model
|
|
const { key, storage } = readKey(model)
|
|
const { error, compaction: _compaction, ...budget } = resolveModelTokenLimits(profile)
|
|
return { ...profile, ...(!error ? budget : {}), supportedEfforts: profile.supportedEfforts?.slice(), hasApiKey: !!key, keyStorage: key ? storage : 'none' }
|
|
}
|
|
|
|
function registryView(registry: StoredRegistry): ModelRegistryView {
|
|
return { models: registry.models.map(viewOf), selectedModelId: registry.selectedModelId }
|
|
}
|
|
|
|
async function persist(registry: StoredRegistry): Promise<void> {
|
|
const file = filePath()
|
|
await fs.mkdir(join(file, '..'), { recursive: true })
|
|
const temporary = `${file}.${randomUUID()}.tmp`
|
|
try {
|
|
await fs.writeFile(temporary, JSON.stringify(registry, null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
await fs.rename(temporary, file)
|
|
cache = registry
|
|
} catch (error) {
|
|
await fs.unlink(temporary).catch(() => undefined)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function readRegistry(persistMigration: boolean): Promise<StoredRegistry> {
|
|
let raw: string
|
|
try { raw = await fs.readFile(filePath(), 'utf8') }
|
|
catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
const legacy = await getGatewayConfig()
|
|
const migrated: StoredModel = {
|
|
id: 'legacy-default', name: legacy.model, providerId: 'custom',
|
|
model: legacy.model, baseUrl: legacy.baseUrl, authMode: 'api-key', protocol: 'anthropic',
|
|
supportsToolCall: true, supportsImages: false, supportsReasoning: false,
|
|
temperature: legacy.temperature, maxToolRounds: legacy.maxToolRounds
|
|
}
|
|
writeKey(migrated, legacy.apiKey)
|
|
const initial: StoredRegistry = { version: 1, models: [migrated], selectedModelId: migrated.id }
|
|
if (persistMigration) await persist(initial)
|
|
return initial
|
|
}
|
|
let parsed: Partial<StoredRegistry> & { schemaVersion?: number }
|
|
try { parsed = JSON.parse(raw) as typeof parsed }
|
|
catch { throw new Error('模型配置文件损坏,请保留 models.json 后检查内容') }
|
|
// Accept either version marker without rewriting existing secrets during a read.
|
|
if (!parsed || typeof parsed !== 'object' || (parsed.version ?? parsed.schemaVersion) !== 1 || !Array.isArray(parsed.models)) {
|
|
throw new Error('无法读取此版本的模型配置文件')
|
|
}
|
|
const ids = new Set<string>()
|
|
for (const model of parsed.models) {
|
|
if (!model || typeof model.id !== 'string' || !model.id || ids.has(model.id)
|
|
|| typeof model.model !== 'string' || typeof model.baseUrl !== 'string'
|
|
|| typeof model.name !== 'string' || typeof model.providerId !== 'string') {
|
|
throw new Error('模型配置内容无效,请保留 models.json 后检查内容')
|
|
}
|
|
ids.add(model.id)
|
|
}
|
|
const selectedModelId = parsed.selectedModelId === null ? null
|
|
: typeof parsed.selectedModelId === 'string' && ids.has(parsed.selectedModelId)
|
|
? parsed.selectedModelId : parsed.models[0]?.id ?? null
|
|
return { version: 1, models: parsed.models, selectedModelId }
|
|
}
|
|
|
|
async function load(): Promise<StoredRegistry> {
|
|
if (cache) return cache
|
|
if (loading) return loading
|
|
loading = readRegistry(true).then(registry => { cache = registry; return registry })
|
|
try { return await loading }
|
|
finally { loading = null }
|
|
}
|
|
|
|
function mutate(operation: (registry: StoredRegistry) => Promise<ModelRegistryView>): Promise<ModelRegistryView> {
|
|
const next = writes.then(async () => operation(await load()))
|
|
writes = next.catch(() => undefined)
|
|
return next
|
|
}
|
|
|
|
function normalize(input: CustomModelInput, existing?: StoredModel, testing = false): StoredModel {
|
|
if (!input || typeof input !== 'object') throw new Error('模型配置无效')
|
|
const providerId = 'custom'
|
|
const model = typeof input.model === 'string' ? input.model.trim() : ''
|
|
const baseUrl = typeof input.baseUrl === 'string' ? normalizeModelAddress(input.baseUrl) : ''
|
|
if (!providerId) throw new Error('请选择供应商')
|
|
if (!model) throw new Error('请输入模型名称')
|
|
if (!baseUrl) throw new Error('请输入 API 地址')
|
|
let endpoint: URL
|
|
try { endpoint = new URL(baseUrl) } catch { throw new Error('请输入有效的 API 地址') }
|
|
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password) throw new Error('API 地址必须使用 http 或 https,且不能包含账户密码')
|
|
if (endpoint.search || endpoint.hash) throw new Error('API 地址不能包含查询参数或片段')
|
|
const id = typeof input.id === 'string' ? input.id.trim() : ''
|
|
const name = typeof input.name === 'string' ? input.name.trim() : ''
|
|
if (!id) throw new Error('模型标识不能为空')
|
|
if (!name) throw new Error('请输入模型显示名称')
|
|
if (input.authMode !== undefined && !['api-key', 'none'].includes(input.authMode)) throw new Error('鉴权方式无效')
|
|
const suppliedKey = typeof input.apiKey === 'string' ? input.apiKey.replace(/\s/gu, '') : ''
|
|
if (suppliedKey && !/^[\x21-\x7e]+$/.test(suppliedKey)) throw new Error('API Key 只能包含 ASCII 可打印字符')
|
|
const authMode = input.authMode ?? 'api-key'
|
|
const key = authMode === 'none' || input.clearApiKey ? '' : suppliedKey || (existing ? readKey(existing).key : '')
|
|
if (authMode !== 'none' && !key && (testing || !input.clearApiKey)) throw new Error('请输入 API Key')
|
|
const next: StoredModel = { id, name, providerId, model, baseUrl, authMode }
|
|
if (input.protocol !== undefined && input.protocol !== 'anthropic') throw new Error('首版仅支持 Anthropic 协议')
|
|
next.protocol = 'anthropic'
|
|
if (input.tokenBudgetVersion !== undefined && input.tokenBudgetVersion !== 2) throw new Error('模型上下文配置版本无效')
|
|
if (input.tokenBudgetVersion === 2) next.tokenBudgetVersion = 2
|
|
const configuredEndpoint = input.endpoint ?? (baseUrl === existing?.baseUrl && next.protocol === existing?.protocol && !suppliedKey ? existing.endpoint : undefined)
|
|
if (configuredEndpoint) {
|
|
if (!['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai'].includes(configuredEndpoint.api)) throw new Error('协商协议无效')
|
|
const root = normalizeModelAddress(configuredEndpoint.baseUrl)
|
|
if (new URL(root).origin !== endpoint.origin || apiProtocol(configuredEndpoint.api) !== 'anthropic') throw new Error('协商地址与填写的域名或协议不一致')
|
|
next.endpoint = { api: configuredEndpoint.api, baseUrl: root, checkedAt: configuredEndpoint.checkedAt,
|
|
...(['models', 'generation'].includes(configuredEndpoint.verifiedBy ?? '') ? { verifiedBy: configuredEndpoint.verifiedBy } : {}) }
|
|
}
|
|
for (const field of ['supportsToolCall', 'supportsImages', 'supportsReasoning', 'onlyReasoning', 'canDisableThinking', 'useCustomProtocol'] as const) {
|
|
if (typeof input[field] === 'boolean') next[field] = input[field]
|
|
}
|
|
if (next.supportsReasoning) {
|
|
if (typeof input.defaultEffort === 'string') next.defaultEffort = input.defaultEffort
|
|
if (Array.isArray(input.supportedEfforts) && input.supportedEfforts.every(value => typeof value === 'string')) next.supportedEfforts = [...new Set(input.supportedEfforts)]
|
|
} else {
|
|
delete next.onlyReasoning
|
|
delete next.canDisableThinking
|
|
}
|
|
for (const field of ['maxInputTokens', 'maxOutputTokens', 'contextWindowTokens', 'reasoningBudget'] as const) {
|
|
const value = input[field]
|
|
if (value !== undefined) {
|
|
if (!Number.isFinite(value)) throw new Error('Token 数量必须是有效数字')
|
|
if (!Number.isSafeInteger(value) || value < 0) throw new Error('Token 数量必须是非负整数')
|
|
next[field] = Math.trunc(value)
|
|
}
|
|
}
|
|
for (const field of ['temperature', 'maxToolRounds'] as const) {
|
|
const value = input[field] ?? existing?.[field]
|
|
if (value !== undefined) {
|
|
if (!Number.isFinite(value)) throw new Error('模型参数必须是有效数字')
|
|
next[field] = field === 'temperature' ? Math.max(0, Math.min(2, value)) : Math.max(1, Math.min(24, Math.round(value)))
|
|
}
|
|
}
|
|
// Preserve ciphertext when editing without a new key (including on platforms temporarily unable to encrypt).
|
|
if (existing && authMode !== 'none' && !input.clearApiKey && !suppliedKey) {
|
|
next.apiKeyEnc = existing.apiKeyEnc
|
|
next.apiKeyPlain = existing.apiKeyPlain
|
|
} else writeKey(next, key)
|
|
return next
|
|
}
|
|
|
|
export async function listModels(): Promise<ModelRegistryView> {
|
|
await writes
|
|
return registryView(await load())
|
|
}
|
|
|
|
export function saveModel(input: CustomModelInput, overwrite = false): Promise<ModelRegistryView> {
|
|
return mutate(async registry => {
|
|
const previousId = input?.previousId || input?.id
|
|
const existing = registry.models.find(model => model.id === previousId)
|
|
const target = registry.models.find(model => model.id === input?.id?.trim())
|
|
if (target && target.id !== input.previousId && !overwrite) throw new Error('同名模型已存在,请确认是否覆盖')
|
|
const next = normalize(input, existing ?? target)
|
|
const metadata = await modelMetadata(next.model, next.protocol ?? 'auto').catch(() => null)
|
|
if (metadata) {
|
|
next.metadata = metadata; next.price = metadata.price
|
|
if (next.protocol) {
|
|
for (const field of ['supportsToolCall', 'supportsImages', 'supportsReasoning', 'contextWindowTokens', 'maxInputTokens', 'maxOutputTokens'] as const) {
|
|
if (input[field] === undefined) (next as unknown as Record<string, unknown>)[field] = metadata[field]
|
|
}
|
|
if (next.supportsReasoning) {
|
|
for (const field of ['onlyReasoning', 'canDisableThinking', 'defaultEffort', 'supportedEfforts'] as const) if (input[field] === undefined) (next as unknown as Record<string, unknown>)[field] = metadata[field]
|
|
if (input.reasoningBudget === undefined && metadata.reasoningOptions.some(option => option.type === 'budget_tokens')) next.reasoningBudget = 1024
|
|
}
|
|
}
|
|
}
|
|
{
|
|
if (input.maxInputTokens === undefined) next.tokenBudgetVersion = 2
|
|
const { error, compaction: _compaction, ...limits } = resolveModelTokenLimits(next)
|
|
if (error) throw new Error(error)
|
|
Object.assign(next, limits)
|
|
}
|
|
if (next.protocol && !next.endpoint) {
|
|
// Discovery is read-only; servers without a models API still support manual model IDs.
|
|
const connection = await discoverModelConnection(next.baseUrl, next.protocol, readKey(next).key).catch(() => undefined)
|
|
if (connection) next.endpoint = connection.endpoint
|
|
}
|
|
const remaining = registry.models.filter(model => model.id !== previousId && model.id !== next.id)
|
|
const oldIndex = registry.models.findIndex(model => model.id === previousId || model.id === next.id)
|
|
remaining.splice(oldIndex < 0 ? remaining.length : Math.min(oldIndex, remaining.length), 0, next)
|
|
const selectedModelId = !registry.selectedModelId || registry.selectedModelId === previousId ? next.id : registry.selectedModelId
|
|
const updated: StoredRegistry = { version: 1, models: remaining, selectedModelId }
|
|
await persist(updated)
|
|
return registryView(updated)
|
|
})
|
|
}
|
|
|
|
export function deleteModel(id: string): Promise<ModelRegistryView> {
|
|
return mutate(async registry => {
|
|
const models = registry.models.filter(model => model.id !== id)
|
|
const selectedModelId = registry.selectedModelId === id ? models[0]?.id ?? null : registry.selectedModelId
|
|
const updated: StoredRegistry = { version: 1, models, selectedModelId }
|
|
await persist(updated)
|
|
return registryView(updated)
|
|
})
|
|
}
|
|
|
|
export function setSelectedModel(id: string | null): Promise<ModelRegistryView> {
|
|
return mutate(async registry => {
|
|
if (id !== null && !registry.models.some(model => model.id === id)) throw new Error('所选模型不存在')
|
|
const updated: StoredRegistry = { ...registry, selectedModelId: id }
|
|
await persist(updated)
|
|
return registryView(updated)
|
|
})
|
|
}
|
|
|
|
export async function resolveModel(id?: string | null): Promise<ResolvedModel | null> {
|
|
await writes
|
|
const registry = await load()
|
|
const targetId = id === undefined ? registry.selectedModelId : id
|
|
if (!targetId) return null
|
|
const model = registry.models.find(model => model.id === targetId)
|
|
if (!model) throw new Error('所选模型不存在,请重新选择模型')
|
|
const budget = resolveModelTokenLimits(model)
|
|
if (budget.error) throw new Error(budget.error)
|
|
const profile = viewOf(model)
|
|
const legacy = await getGatewayConfig()
|
|
const verified = model.protocol ? cachedGeneration(model.baseUrl, model.protocol, readKey(model).key, model.model) : undefined
|
|
const resolved = verified && verified.checkedAt >= (model.endpoint?.checkedAt ?? 0) ? verified : model.endpoint
|
|
?? (model.protocol ? cachedConnection(model.baseUrl, model.protocol, readKey(model).key)?.endpoint ?? modelEndpoint(model.baseUrl, inferModelApi(model.baseUrl, model.protocol)) : undefined)
|
|
return {
|
|
profile,
|
|
config: { ...legacy, baseUrl: resolved?.baseUrl ?? model.baseUrl, api: resolved?.api, model: model.model, apiKey: readKey(model).key,
|
|
supportsReasoning: model.supportsReasoning, reasoningBudget: model.reasoningBudget, reasoningOptions: model.metadata?.reasoningOptions,
|
|
temperatureSupported: model.metadata?.temperature,
|
|
authMode: model.authMode, maxOutputTokens: profile.maxOutputTokens,
|
|
temperature: model.temperature ?? legacy.temperature, maxToolRounds: model.maxToolRounds ?? legacy.maxToolRounds }
|
|
}
|
|
}
|
|
|
|
export async function fetchModels(input: ModelDiscoveryInput): Promise<ModelDiscoveryResult> {
|
|
if (!input || typeof input.baseUrl !== 'string' || (input.protocol !== undefined && input.protocol !== 'anthropic')) throw new Error('首版仅支持 Anthropic 协议')
|
|
await writes
|
|
const registry = cache ?? await readRegistry(false), existing = registry.models.find(model => model.id === input.previousId)
|
|
const key = input.authMode === 'none' ? '' : typeof input.apiKey === 'string' && input.apiKey.trim() ? input.apiKey.replace(/\s/gu, '') : existing ? readKey(existing).key : ''
|
|
if (!key && input.authMode !== 'none') throw new Error('请输入 API Key')
|
|
return discoverModelConnection(input.baseUrl, 'anthropic', key, true)
|
|
}
|
|
|
|
/** Test only the user-specified origin, negotiating paths/protocols before reporting success. */
|
|
export async function testModel(input: CustomModelInput): Promise<ModelTestResult> {
|
|
const startedAt = Date.now()
|
|
const result = (success: boolean, message: string): ModelTestResult => ({ success, message, durationMs: Date.now() - startedAt })
|
|
let model: StoredModel
|
|
try {
|
|
await writes
|
|
const registry = cache ?? await readRegistry(false)
|
|
model = normalize(input, registry.models.find(item => item.id === (input.previousId || input.id)), true)
|
|
} catch (error) { return result(false, (error as Error).message) }
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), 15_000)
|
|
try {
|
|
const key = readKey(model).key
|
|
const legacy = resolveOpenAIEndpoint(model.baseUrl)
|
|
const known = model.protocol ? model.endpoint ?? cachedConnection(model.baseUrl, model.protocol, key)?.endpoint : undefined
|
|
// Re-testing an OpenAI route follows the current preference, including at a negotiated custom root.
|
|
// Stored routes continue to serve ordinary chat until a new generation probe succeeds.
|
|
const preferred = known && (known.api === 'openai-completions' || known.api === 'openai-responses')
|
|
? { ...known, api: inferModelApi(model.baseUrl, model.protocol) } : known
|
|
const candidates: ModelEndpoint[] = model.protocol ? [preferred, ...endpointCandidates(model.baseUrl, model.protocol), known].filter((value): value is ModelEndpoint => !!value)
|
|
: [{ ...legacy, checkedAt: 0 }]
|
|
const tried = new Set<string>(); let status = 0
|
|
for (const endpoint of candidates) {
|
|
const url = model.protocol ? endpointUrl(endpoint, 'generate', model.model) : legacy.endpoint
|
|
if (tried.has(url + endpoint.api)) continue
|
|
tried.add(url + endpoint.api)
|
|
const payload = endpoint.api === 'openai-responses' ? { model: model.model, input: 'Reply with OK.', stream: false, max_output_tokens: 16 }
|
|
: endpoint.api === 'anthropic-messages' ? { model: model.model, messages: [{ role: 'user', content: 'Reply with OK.' }], max_tokens: 16 }
|
|
: endpoint.api === 'google-generative-ai' ? { contents: [{ role: 'user', parts: [{ text: 'Reply with OK.' }] }], generationConfig: { maxOutputTokens: 16 } }
|
|
: { model: model.model, messages: [{ role: 'user', content: 'Reply with OK.' }], stream: false, [openAICompletionTokenField(model.model)]: 16 }
|
|
const response = await modelFetch(url, { method: 'POST', signal: controller.signal, headers: modelHeaders(endpoint.api, key), body: JSON.stringify(payload) })
|
|
status = response.status
|
|
if (!response.ok) { await response.body?.cancel(); if ([401, 403, 429].includes(status) && model.protocol !== 'auto') break; continue }
|
|
let parsed: unknown
|
|
try { parsed = await readModelJson(response, 1024 * 1024) } catch { continue }
|
|
const value = parsed as Record<string, unknown> | null
|
|
const valid = endpoint.api === 'openai-responses' ? isResponsesTestResult(parsed) : endpoint.api === 'anthropic-messages'
|
|
? value?.type === 'message' && Array.isArray(value.content) : endpoint.api === 'google-generative-ai'
|
|
? Array.isArray(value?.candidates) && value.candidates.length > 0 : Array.isArray(value?.choices) && value.choices.some(choice => choice && (choice.message || typeof choice.text === 'string'))
|
|
if (!valid) continue
|
|
const verified: ModelEndpoint = { api: endpoint.api, baseUrl: endpoint.baseUrl, checkedAt: Date.now(), verifiedBy: 'generation' }
|
|
if (model.protocol) rememberGeneration(model.baseUrl, model.protocol, key, model.model, verified)
|
|
return { ...result(true, '连接测试成功'), endpoint: verified }
|
|
}
|
|
const messages: Record<number, string> = { 401: '鉴权失败,请检查 API Key', 403: '上游拒绝访问,请检查 API Key 和权限', 404: '未找到模型或接口,请检查模型名称和 API 地址', 429: '上游请求频率或额度受限,请稍后重试' }
|
|
return result(false, messages[status] ?? (status >= 400 ? `上游返回 HTTP ${status}` : '上游未返回兼容的对话结果'))
|
|
} catch (error) {
|
|
if (controller.signal.aborted) return result(false, '连接超时,请检查 API 地址或稍后重试')
|
|
return result(false, error instanceof SyntaxError ? '上游未返回有效的 JSON 对话结果' : '无法连接上游,请检查 API 地址和网络')
|
|
} finally { clearTimeout(timer) }
|
|
}
|
|
|
|
/** A short probe may spend its whole output budget on reasoning without producing visible text. */
|
|
function isResponsesTestResult(value: unknown): boolean {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
const response = value as Record<string, unknown>
|
|
if (response.error != null || (response.object !== undefined && response.object !== 'response')
|
|
|| (response.status !== undefined && response.status !== 'completed' && response.status !== 'incomplete')
|
|
|| !Array.isArray(response.output)) return false
|
|
const validOutput = response.output.every(item => {
|
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return false
|
|
const output = item as Record<string, unknown>
|
|
if (output.type === 'reasoning') {
|
|
return Array.isArray(output.summary) && output.summary.every(part => part && typeof part === 'object'
|
|
&& part.type === 'summary_text' && typeof part.text === 'string')
|
|
}
|
|
return output.type === 'message' && output.role === 'assistant' && Array.isArray(output.content)
|
|
&& output.content.length > 0 && output.content.every(part => part && typeof part === 'object'
|
|
&& ((part.type === 'output_text' && typeof part.text === 'string')
|
|
|| (part.type === 'refusal' && typeof part.refusal === 'string')))
|
|
})
|
|
if (!validOutput) return false
|
|
if (response.status === 'incomplete') {
|
|
const details = response.incomplete_details
|
|
return !!details && typeof details === 'object' && 'reason' in details
|
|
&& (details.reason === 'max_output_tokens' || details.reason === 'content_filter')
|
|
}
|
|
return response.output.length > 0
|
|
}
|