Rebuilt on develop (after #176) and reworked per review: instead of inferring the provider from baseUrl (`if (baseUrl)`), the admin picks the chat provider EXPLICITLY via a new `chatApiStyle` ('openai-compatible' | 'openai'), mirroring the existing sttApiStyle. A custom baseURL can front real OpenAI too, so the heuristic was fragile. Why reasoning was missing: glm-5.2 (and DeepSeek etc.) stream their thinking as `reasoning_content`, but the official @ai-sdk/openai provider does not map that field. 'openai-compatible' uses @ai-sdk/openai-compatible, which does — so reasoning parts now stream (verified live: reasoning-start/delta/end appear, and disappear when set to 'openai'). - Default (unset) = 'openai-compatible', so existing openai+baseUrl workspaces surface reasoning with no admin action. No DB migration (field lives in the settings.ai.provider JSON blob). - includeUsage: true on the openai-compatible model — without it the provider omits streamed usage, zeroing the live token counter / reasoning-token metadata. The official provider always sent it; this keeps parity. (Confirmed live: usage.totalTokens present.) - openai-compatible has no default endpoint, so with no baseURL (real OpenAI, or a role's cross-driver override that cleared it) it falls back to the official provider. Plumbing: ai.types (ChatApiStyle / CHAT_API_STYLES + AiProviderSettings / MaskedAiSettings), update DTO (@IsIn), ai-settings.service (resolve / getMasked / update allowlist), workspace.repo updateAiProviderSettings ALLOWED (the second, SQL-level allowlist the review missed — without it the field never persisted), ai.service selector. Client: ai-settings-service types + a Protocol <Select> in the chat section + i18n (en/ru). Scope is chat-only (embeddings don't stream reasoning; STT already has sttApiStyle). Tests: ai.service.spec — 4 cases (openai-compatible+baseURL, openai+baseURL, default-unset, openai-compatible-without-baseURL fallback). Verified on the stand: default streams reasoning + usage; 'openai' drops reasoning; the setting round-trips. server + client tsc clean; 36 ai/settings specs green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
89 lines
1.8 KiB
TypeScript
89 lines
1.8 KiB
TypeScript
import { IsIn, IsOptional, IsString } from 'class-validator';
|
|
import {
|
|
AI_DRIVERS,
|
|
AiDriver,
|
|
CHAT_API_STYLES,
|
|
ChatApiStyle,
|
|
STT_API_STYLES,
|
|
SttApiStyle,
|
|
} from '../ai.types';
|
|
|
|
/**
|
|
* Admin update payload for the workspace AI provider settings.
|
|
*
|
|
* `apiKey` / `embeddingApiKey` / `sttApiKey` are write-only (§8.2): provided →
|
|
* stored encrypted, '' → cleared, absent → left untouched. They are NEVER
|
|
* returned by any endpoint. The global ValidationPipe runs with
|
|
* `whitelist: true`, so unknown fields are stripped.
|
|
*/
|
|
export class UpdateAiSettingsDto {
|
|
@IsOptional()
|
|
@IsIn(AI_DRIVERS)
|
|
driver?: AiDriver;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
chatModel?: string;
|
|
|
|
@IsOptional()
|
|
@IsIn(CHAT_API_STYLES)
|
|
chatApiStyle?: ChatApiStyle;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
embeddingModel?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
baseUrl?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
embeddingBaseUrl?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
systemPrompt?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
apiKey?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
embeddingApiKey?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
sttModel?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
sttBaseUrl?: string;
|
|
|
|
@IsOptional()
|
|
@IsIn(STT_API_STYLES)
|
|
sttApiStyle?: SttApiStyle;
|
|
|
|
// ISO-639-1 dictation language hint (e.g. 'en', 'ru'). Empty = auto-detect.
|
|
@IsOptional()
|
|
@IsString()
|
|
sttLanguage?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
sttApiKey?: string;
|
|
|
|
// Cheap model id for the anonymous public-share assistant; reuses the chat
|
|
// driver/baseUrl/apiKey. Empty → the assistant falls back to chatModel.
|
|
@IsOptional()
|
|
@IsString()
|
|
publicShareChatModel?: string;
|
|
|
|
// Agent-role id whose persona the anonymous public-share assistant adopts;
|
|
// empty/unset = built-in locked persona.
|
|
@IsOptional()
|
|
@IsString()
|
|
publicShareAssistantRoleId?: string;
|
|
}
|