feat(ai-chat): surface reasoning from openai-compatible providers (z.ai/GLM) (#175) #177
Reference in New Issue
Block a user
Delete Branch "feat/reasoning-openai-compatible"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Связано с #175. Делает reasoning агента видимым — через ЯВНЫЙ выбор провайдера.
Зачем
glm-5.2 (и DeepSeek и пр.) стримят размышления как
reasoning_content, но официальный @ai-sdk/openai это поле не мапит → reasoning терялся.Дизайн (по ревью)
Вместо вывода по baseUrl — явный
chatApiStyle: 'openai-compatible' | 'openai'(зеркало sttApiStyle, без миграции — поле в settings.ai.provider JSON):reasoning_contentв reasoning-парты (z.ai/GLM, DeepSeek, …).includeUsage: trueсохраняет стримовый usage (иначе обнуляются счётчики токенов).Кастомный baseURL может фронтить и реальный OpenAI (Azure/прокси/o1) — поэтому выбор явный, а не по URL.
Файлы
Бэк: ai.types, update DTO, ai-settings.service (resolve/getMasked/update-allowlist), workspace.repo ALLOWED (второй SQL-allowlist, без него поле не персистилось), ai.service-селектор. Фронт: типы +
<Select>Protocol + i18n. Скоуп только chat.Проверка на стенде
default → reasoning + usage; 'openai' → reasoning пропадает; round-trip настройки работает. 4 теста на селектор; server+client tsc + 36 специ зелёные.
Комплементарно к #176 (таймауты, уже влит).
🤖 Generated with Claude Code
Дизайн: явный селектор «тип протокола» вместо эвристики
if (baseUrl)По итогам ревью этого PR: текущий подход выбирает реализацию провайдера по факту наличия
baseUrl(createOpenAICompatibleпри кастомном URL, иначе официальныйcreateOpenAI). Это хрупко — комбинация «драйверopenai+ кастомныйbaseUrl» не означает «всегда сторонний openai-compatible»: за кастомным URL может стоять и реальный OpenAI (Azure/корпоративный прокси/реальные reasoning-модели o1/o3/gpt-5), для которого официальный провайдер делает спец-формирование запроса (max_completion_tokens, рольdeveloper), а openai-compatible — нет.Решение: убрать вывод по
baseUrlи дать админу явно выбирать тип протокола, по образцу уже существующегоsttApiStyle: 'multipart' | 'json'.Модель данных
Новое необязательное поле в
settings.ai.provider(это JSON-блоб вworkspace.settings— миграция БД не нужна):'openai-compatible'→@ai-sdk/openai-compatible(createOpenAICompatible): мапит стримовыйreasoning_contentв reasoning-парты (z.ai/GLM, DeepSeek, OpenRouter, …) — то, ради чего затевался #175.'openai'→ официальный@ai-sdk/openaicreateOpenAI(...).chat(): спец-формирование для reasoning-моделей реального OpenAI, без маппинга стороннегоreasoning_content.Дефолт (поле не задано) =
'openai-compatible'. Это сохраняет поведение/цель PR: в этом форке UI всегда конфигурируетopenai+baseUrlи ходит в/chat/completions, так что существующие воркспейсы продолжат видеть reasoning без действий админа.Логика выбора (
ai.service.ts,getChatModel,case 'openai')chatApiStyleчитается из resolved-конфига рядом сbaseUrl/apiKey.Два замечания из ревью закрываются здесь же:
includeUsage: trueустраняет тихую регрессию:@ai-sdk/openai-compatible@2.0.37шлётstream_options.include_usageтолько приincludeUsage, иначе usage в стриме =undefined→ обнуляютсяmetadata.usage/contextTokens, живой счётчик токенов и reasoning-токены. Официальный провайдер всегда слалinclude_usage: true.openaiдля такого эндпоинта.Затрагиваемые файлы (точечные правки)
Бэкенд:
apps/server/src/integrations/ai/ai.types.ts— типChatApiStyle,CHAT_API_STYLES, поле вAiProviderSettingsиMaskedAiSettings.apps/server/src/integrations/ai/dto/update-ai-settings.dto.ts—@IsOptional() @IsIn(CHAT_API_STYLES) chatApiStyle?.apps/server/src/integrations/ai/ai-settings.service.ts—chatApiStyleвUpdateAiSettingsInput, в объектеresolve(), вgetMasked(), и в allowlist полей вupdate().apps/server/src/integrations/ai/ai.service.ts— логика выше + чтениеchatApiStyleиз cfg.Фронтенд:
apps/client/src/features/workspace/services/ai-settings-service.ts— тип + поля вIAiSettings/IAiSettingsUpdate.apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx— поле схемыchatApiStyle: z.enum(["openai-compatible","openai"]), дефолт/гидрация (?? "openai-compatible"), payload, и<Select>в chat-секции (после поля Base URL), по образцу Select дляsttApiStyle:Скоуп
Только chat-путь. Embeddings (
getEmbeddingModel) не нужны — reasoning они не стримят; STT уже имеет собственныйsttApiStyle. Это устраняет и расхождение «chat vs embeddings», отмеченное в ревью (выбор остаётся осознанным, а не неявным).Тесты (
ai.service.spec.ts)Перевести существующие кейсы с «по baseUrl» на «по
chatApiStyle» и добавить:chatApiStyle: 'openai-compatible'(+ baseUrl) →model.providerсодержит'openai-compatible';chatApiStyle: 'openai'(+ baseUrl) →model.provider === 'openai.chat';openai-compatible;chatApiStyle: 'openai-compatible'без baseUrl → безопасный откат наopenai.chat.На обсуждение
chatApiStyleсо значениями'openai-compatible' | 'openai'(зеркалитsttApiStyle). Альтернатива —protocolсо значениями'compatible' | 'official'.Auto(= нынешнее поведение по baseUrl)? Считаю, что нет — он сохраняет ровно ту хрупкость, от которой уходим.🤖 Generated with Claude Code
fe1bbbe806to59190148dbРевью #177 — что надо сделать
Регрессию со сменой дефолта на
openai-compatible(reasoning-модель реального OpenAI заbaseUrl) сознательно оставляем как есть — намеренное поведение. Остальное:Тесты
chatApiStyle. Сейчас они проверяют только.provider, а это не ловит проводку:.providerодинаков вне зависимости от того, передан лиincludeUsage. Регрессия, дропающаяincludeUsage: true(ровно тот баг с обнулением счётчика токенов, от которого предостерегает комментарий), пройдёт зелёной. Надо замокать@ai-sdk/openai-compatibleи проверить, что фабрика вызвана с{ includeUsage: true, baseURL, apiKey, fetch }.@IsIn(CHAT_API_STYLES)(отклоняет мусор, принимает оба валидных значения) и round-trip персистаchatApiStyle(update-allowlist → SQLALLOWED→resolve/getMasked). Не обязательно — соответствует текущей практике (sttApiStyleуехал без таких тестов).CHANGELOG
[Unreleased]про новую настройку «Protocol» /chatApiStyleи смену дефолта (openai → openai-compatible), с рефом (#177) — изменение admin-видимое, ровно тот класс, что туда пишется.Архитектура (вариант А)
ai-settings.service.tsи SQLALLOWEDвworkspace.repo.ts. Пропуск во втором молча ломает персист (как и было с этим полем — поле валидируется, проходит сервис, отсеивается на SQL-границе, без ошибки).ai.types.ts:satisfiesзаставит компилятор отвергать опечатку или ключ, которого нет вAiProviderSettings.ai-settings.service.ts: брать key-loop изPROVIDER_SETTINGS_KEYSвместо локального массива.workspace.repo.tsостаётся generic-слоем (не импортит AI-типы) — добавить parity-тест, утверждающий, что егоALLOWEDравенPROVIDER_SETTINGS_KEYS, чтобы любое будущее расхождение падало в CI, а не молча в проде.🤖 Сгенерировано в рамках code-review (Claude Code)