feat(ai-chat): explicit chatApiStyle selector to surface reasoning (#175)

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>
This commit is contained in:
claude code agent 227
2026-06-24 22:58:15 +03:00
parent 80a4b5a1b0
commit 59190148db
10 changed files with 170 additions and 12 deletions

View File

@@ -1307,5 +1307,9 @@
"Page tree (child pages, recursive)": "Page tree (child pages, recursive)", "Page tree (child pages, recursive)": "Page tree (child pages, recursive)",
"Render the full nested tree of all descendant pages": "Render the full nested tree of all descendant pages", "Render the full nested tree of all descendant pages": "Render the full nested tree of all descendant pages",
"Showing {{count}} subpages_one": "Showing {{count}} subpage", "Showing {{count}} subpages_one": "Showing {{count}} subpage",
"Showing {{count}} subpages_other": "Showing {{count}} subpages" "Showing {{count}} subpages_other": "Showing {{count}} subpages",
"Protocol": "Protocol",
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
"OpenAI (official)": "OpenAI (official)"
} }

View File

@@ -1160,5 +1160,9 @@
"Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц", "Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц",
"Showing {{count}} subpages_one": "Показано {{count}} подстраница", "Showing {{count}} subpages_one": "Показано {{count}} подстраница",
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы", "Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц" "Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
"Protocol": "Протокол",
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
"OpenAI (official)": "OpenAI (официальный)"
} }

View File

@@ -38,6 +38,7 @@ import {
AiTestCapability, AiTestCapability,
IAiSettingsUpdate, IAiSettingsUpdate,
SttApiStyle, SttApiStyle,
ChatApiStyle,
} from "@/features/workspace/services/ai-settings-service.ts"; } from "@/features/workspace/services/ai-settings-service.ts";
import { useAiRolesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts"; import { useAiRolesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts"; import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
@@ -82,6 +83,8 @@ const STT_LANGUAGE_OPTIONS: { value: string; label: string }[] = [
// (empty means "leave unchanged" unless explicitly cleared). // (empty means "leave unchanged" unless explicitly cleared).
const formSchema = z.object({ const formSchema = z.object({
chatModel: z.string(), chatModel: z.string(),
// Chat provider implementation (reasoning surfacing). Default openai-compatible.
chatApiStyle: z.enum(["openai-compatible", "openai"]),
// Cheap model id for the anonymous public-share assistant; empty = use chatModel. // Cheap model id for the anonymous public-share assistant; empty = use chatModel.
publicShareChatModel: z.string(), publicShareChatModel: z.string(),
// Agent-role id whose persona the public-share assistant adopts; empty = // Agent-role id whose persona the public-share assistant adopts; empty =
@@ -308,6 +311,7 @@ export default function AiProviderSettings() {
validate: zod4Resolver(formSchema), validate: zod4Resolver(formSchema),
initialValues: { initialValues: {
chatModel: "", chatModel: "",
chatApiStyle: "openai-compatible" as ChatApiStyle,
publicShareChatModel: "", publicShareChatModel: "",
publicShareAssistantRoleId: "", publicShareAssistantRoleId: "",
embeddingModel: "", embeddingModel: "",
@@ -330,6 +334,7 @@ export default function AiProviderSettings() {
if (!settings) return; if (!settings) return;
form.setValues({ form.setValues({
chatModel: settings.chatModel ?? "", chatModel: settings.chatModel ?? "",
chatApiStyle: settings.chatApiStyle ?? "openai-compatible",
publicShareChatModel: settings.publicShareChatModel ?? "", publicShareChatModel: settings.publicShareChatModel ?? "",
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "", publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
embeddingModel: settings.embeddingModel ?? "", embeddingModel: settings.embeddingModel ?? "",
@@ -359,6 +364,7 @@ export default function AiProviderSettings() {
// Everything is OpenAI-compatible. // Everything is OpenAI-compatible.
driver: "openai", driver: "openai",
chatModel: values.chatModel, chatModel: values.chatModel,
chatApiStyle: values.chatApiStyle,
// Cheap model id for the anonymous public-share assistant; empty falls // Cheap model id for the anonymous public-share assistant; empty falls
// back to chatModel server-side. // back to chatModel server-side.
publicShareChatModel: values.publicShareChatModel, publicShareChatModel: values.publicShareChatModel,
@@ -761,6 +767,24 @@ export default function AiProviderSettings() {
{t("Resolves to {{url}}", { url: chatResolved })} {t("Resolves to {{url}}", { url: chatResolved })}
</Text> </Text>
<Select
mt="sm"
label={t("Protocol")}
description={t(
"How chat requests are sent and how reasoning is surfaced",
)}
data={[
{
value: "openai-compatible",
label: t("OpenAI-compatible (surfaces reasoning)"),
},
{ value: "openai", label: t("OpenAI (official)") },
]}
allowDeselect={false}
disabled={isLoading}
{...form.getInputProps("chatApiStyle")}
/>
{/* Anonymous public-share assistant: a single master toggle + an {/* Anonymous public-share assistant: a single master toggle + an
optional cheaper model id. Reuses this card's driver/URL/key. */} optional cheaper model id. Reuses this card's driver/URL/key. */}
<Group justify="space-between" align="center" wrap="nowrap" mt="md"> <Group justify="space-between" align="center" wrap="nowrap" mt="md">

View File

@@ -9,6 +9,12 @@ export type AiDriver = "openai" | "gemini" | "ollama";
// - 'json' -> JSON body with base64-encoded audio (OpenRouter) // - 'json' -> JSON body with base64-encoded audio (OpenRouter)
export type SttApiStyle = "multipart" | "json"; export type SttApiStyle = "multipart" | "json";
// Chat provider implementation for the `openai` driver (chosen explicitly):
// - 'openai-compatible' -> maps streamed reasoning_content to reasoning parts
// (z.ai/GLM, DeepSeek, OpenRouter, ...). Default.
// - 'openai' -> official provider; real-OpenAI reasoning-model shaping.
export type ChatApiStyle = "openai-compatible" | "openai";
// Masked AI provider settings returned by the server. // Masked AI provider settings returned by the server.
// No API key is ever returned; only `hasApiKey` / `hasEmbeddingApiKey` indicate // No API key is ever returned; only `hasApiKey` / `hasEmbeddingApiKey` indicate
// whether one is stored. `embeddingBaseUrl` is the RAW stored value (empty means // whether one is stored. `embeddingBaseUrl` is the RAW stored value (empty means
@@ -16,6 +22,7 @@ export type SttApiStyle = "multipart" | "json";
export interface IAiSettings { export interface IAiSettings {
driver?: AiDriver; driver?: AiDriver;
chatModel?: string; chatModel?: string;
chatApiStyle?: ChatApiStyle;
// Cheap model id for the anonymous public-share assistant; empty = chatModel. // Cheap model id for the anonymous public-share assistant; empty = chatModel.
publicShareChatModel?: string; publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty = // Agent-role id whose persona the public-share assistant adopts; empty =
@@ -49,6 +56,7 @@ export interface IAiSettings {
export interface IAiSettingsUpdate { export interface IAiSettingsUpdate {
driver?: AiDriver; driver?: AiDriver;
chatModel?: string; chatModel?: string;
chatApiStyle?: ChatApiStyle;
publicShareChatModel?: string; publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty = // Agent-role id whose persona the public-share assistant adopts; empty =
// built-in locked persona. // built-in locked persona.

View File

@@ -239,7 +239,7 @@ export class WorkspaceRepo {
// is a real jsonb object, never a double-encoded string. The CASE self-heals // is a real jsonb object, never a double-encoded string. The CASE self-heals
// workspaces whose settings.ai.provider was previously corrupted into an // workspaces whose settings.ai.provider was previously corrupted into an
// array/string. // array/string.
const ALLOWED = ['driver', 'chatModel', 'embeddingModel', 'baseUrl', 'embeddingBaseUrl', 'sttModel', 'sttBaseUrl', 'sttApiStyle', 'sttLanguage', 'systemPrompt', 'publicShareChatModel', 'publicShareAssistantRoleId']; const ALLOWED = ['driver', 'chatModel', 'chatApiStyle', 'embeddingModel', 'baseUrl', 'embeddingBaseUrl', 'sttModel', 'sttBaseUrl', 'sttApiStyle', 'sttLanguage', 'systemPrompt', 'publicShareChatModel', 'publicShareAssistantRoleId'];
const entries = Object.entries(provider).filter( const entries = Object.entries(provider).filter(
([k, v]) => v !== undefined && ALLOWED.includes(k), ([k, v]) => v !== undefined && ALLOWED.includes(k),
); );

View File

@@ -14,6 +14,7 @@ import {
MaskedAiSettings, MaskedAiSettings,
ResolvedAiConfig, ResolvedAiConfig,
SttApiStyle, SttApiStyle,
ChatApiStyle,
} from './ai.types'; } from './ai.types';
/** /**
@@ -24,6 +25,7 @@ import {
export interface UpdateAiSettingsInput { export interface UpdateAiSettingsInput {
driver?: AiDriver; driver?: AiDriver;
chatModel?: string; chatModel?: string;
chatApiStyle?: ChatApiStyle;
embeddingModel?: string; embeddingModel?: string;
baseUrl?: string; baseUrl?: string;
embeddingBaseUrl?: string; embeddingBaseUrl?: string;
@@ -157,6 +159,8 @@ export class AiSettingsService {
const config: ResolvedAiConfig = { const config: ResolvedAiConfig = {
driver: provider.driver, driver: provider.driver,
chatModel: provider.chatModel, chatModel: provider.chatModel,
// Plain passthrough; getChatModel defaults unset to 'openai-compatible'.
chatApiStyle: provider.chatApiStyle,
// Cheap model id for the anonymous public-share assistant; reuses the chat // Cheap model id for the anonymous public-share assistant; reuses the chat
// driver/baseUrl/apiKey. Empty/unset → callers fall back to chatModel. // driver/baseUrl/apiKey. Empty/unset → callers fall back to chatModel.
publicShareChatModel: provider.publicShareChatModel, publicShareChatModel: provider.publicShareChatModel,
@@ -238,6 +242,7 @@ export class AiSettingsService {
return { return {
driver: provider.driver, driver: provider.driver,
chatModel: provider.chatModel, chatModel: provider.chatModel,
chatApiStyle: provider.chatApiStyle,
embeddingModel: provider.embeddingModel, embeddingModel: provider.embeddingModel,
baseUrl: provider.baseUrl, baseUrl: provider.baseUrl,
embeddingBaseUrl: provider.embeddingBaseUrl, embeddingBaseUrl: provider.embeddingBaseUrl,
@@ -278,6 +283,7 @@ export class AiSettingsService {
for (const key of [ for (const key of [
'driver', 'driver',
'chatModel', 'chatModel',
'chatApiStyle',
'embeddingModel', 'embeddingModel',
'baseUrl', 'baseUrl',
'embeddingBaseUrl', 'embeddingBaseUrl',

View File

@@ -285,3 +285,64 @@ describe('AiService.getChatModel role model override', () => {
); );
}); });
}); });
/**
* Chat provider selection by the EXPLICIT `chatApiStyle` (NOT inferred from
* baseUrl): 'openai-compatible' (default) uses @ai-sdk/openai-compatible, which
* maps streamed reasoning_content to reasoning parts; 'openai' uses the official
* provider; and openai-compatible without a baseURL safely falls back to the
* official provider (it has no default endpoint). Asserted via `.provider`.
*/
describe('AiService.getChatModel chatApiStyle provider selection', () => {
function serviceWith(opts: {
baseUrl?: string;
chatApiStyle?: 'openai-compatible' | 'openai';
}) {
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: 'openai',
chatModel: 'glm-5.2',
apiKey: 'key',
baseUrl: opts.baseUrl,
chatApiStyle: opts.chatApiStyle,
}),
};
return new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
{ find: jest.fn() } as never,
{ decryptSecret: jest.fn() } as never,
);
}
const providerOf = async (svc: AiService) =>
(
(await svc.getChatModel('ws-1')) as { provider: string }
).provider;
it("'openai-compatible' + baseURL -> openai-compatible provider", async () => {
expect(
await providerOf(
serviceWith({ baseUrl: 'https://api.z.ai/v4', chatApiStyle: 'openai-compatible' }),
),
).toContain('openai-compatible');
});
it("'openai' + baseURL -> official openai provider", async () => {
expect(
await providerOf(serviceWith({ baseUrl: 'https://api.z.ai/v4', chatApiStyle: 'openai' })),
).toBe('openai.chat');
});
it('unset + baseURL -> defaults to openai-compatible', async () => {
expect(
await providerOf(serviceWith({ baseUrl: 'https://api.z.ai/v4' })),
).toContain('openai-compatible');
});
it("'openai-compatible' WITHOUT baseURL -> safe fallback to official openai", async () => {
expect(
await providerOf(serviceWith({ chatApiStyle: 'openai-compatible' })),
).toBe('openai.chat');
});
});

View File

@@ -7,6 +7,7 @@ import {
type LanguageModel, type LanguageModel,
} from 'ai'; } from 'ai';
import { createOpenAI } from '@ai-sdk/openai'; import { createOpenAI } from '@ai-sdk/openai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { createOllama } from 'ai-sdk-ollama'; import { createOllama } from 'ai-sdk-ollama';
import { AiSettingsService } from './ai-settings.service'; import { AiSettingsService } from './ai-settings.service';
@@ -95,6 +96,10 @@ export class AiService {
let apiKey = cfg.apiKey; let apiKey = cfg.apiKey;
let baseUrl = cfg.baseUrl; let baseUrl = cfg.baseUrl;
// Chat provider implementation, chosen EXPLICITLY by the admin (not inferred
// from baseUrl). Unset → 'openai-compatible' so reasoning is surfaced by
// default for this fork's openai+baseUrl setups.
const chatApiStyle = cfg.chatApiStyle ?? 'openai-compatible';
// A driver override that differs from the workspace driver needs that // A driver override that differs from the workspace driver needs that
// driver's own creds (the workspace driver's key would be wrong/absent). // driver's own creds (the workspace driver's key would be wrong/absent).
@@ -145,19 +150,41 @@ export class AiService {
} }
switch (driver) { switch (driver) {
case 'openai': case 'openai': {
// baseURL (when set) covers openai-compatible endpoints. Use Chat // The provider implementation is chosen by the admin's `chatApiStyle`
// Completions (/chat/completions) — the portable OpenAI-compatible // (NOT inferred from baseUrl — a custom URL can front real OpenAI too).
// endpoint. The default callable createOpenAI(...)(model) targets the // Both branches hit Chat Completions (/chat/completions); the provider
// Responses API (/responses), which OpenAI-compatible gateways // fetch is the instrumented streaming fetch (finite-but-generous stream
// (OpenRouter, etc.) reject on multi-turn requests (history with // timeouts, #175).
// assistant messages) → 400. The provider fetch is the instrumented //
// streaming fetch (finite-but-generous stream timeouts, #175). // 'openai-compatible' (default) maps the third-party provider's streamed
// `reasoning_content` to reasoning parts (z.ai/GLM, DeepSeek, ...) — the
// point of #175. It has no default endpoint, so it requires a baseURL;
// when there is none (real OpenAI, or a role's cross-driver override that
// cleared baseUrl) we fall back to the official provider.
if (chatApiStyle === 'openai-compatible' && baseUrl) {
return createOpenAICompatible({
name: 'openai-compatible',
apiKey,
baseURL: baseUrl,
// Keep streamed token usage (stream_options.include_usage): without
// it @ai-sdk/openai-compatible omits usage, zeroing the live token
// counter and reasoning-token metadata. The official provider always
// sent it, so this preserves parity.
includeUsage: true,
fetch: this.aiProviderFetch,
})(chatModel);
}
// Official @ai-sdk/openai: real-OpenAI reasoning-model request shaping;
// `.chat()` targets Chat Completions (the default callable targets the
// Responses API, which openai-compatible gateways 400 on multi-turn
// history). In this fork baseUrl is normally set; undefined = real OpenAI.
return createOpenAI({ return createOpenAI({
apiKey, apiKey,
baseURL: baseUrl, baseURL: baseUrl,
fetch: this.aiProviderFetch, fetch: this.aiProviderFetch,
}).chat(chatModel); }).chat(chatModel);
}
case 'gemini': case 'gemini':
return createGoogleGenerativeAI({ apiKey })(chatModel); return createGoogleGenerativeAI({ apiKey })(chatModel);
case 'ollama': case 'ollama':

View File

@@ -16,6 +16,15 @@ export const AI_DRIVERS: AiDriver[] = ['openai', 'gemini', 'ollama'];
export type SttApiStyle = 'multipart' | 'json'; export type SttApiStyle = 'multipart' | 'json';
export const STT_API_STYLES: SttApiStyle[] = ['multipart', 'json']; export const STT_API_STYLES: SttApiStyle[] = ['multipart', 'json'];
// Chat provider implementation for the `openai` driver. Chosen explicitly by the
// admin (NOT inferred from baseUrl — a custom URL can front real OpenAI too).
// 'openai-compatible' = @ai-sdk/openai-compatible: maps streamed
// `reasoning_content` to reasoning parts (z.ai/GLM, DeepSeek, OpenRouter, ...).
// 'openai' = official @ai-sdk/openai: real-OpenAI reasoning-model request shaping
// (max_completion_tokens, the 'developer' role), no third-party reasoning map.
export type ChatApiStyle = 'openai-compatible' | 'openai';
export const CHAT_API_STYLES: ChatApiStyle[] = ['openai-compatible', 'openai'];
/** /**
* Non-secret provider settings persisted under `settings.ai.provider`. * Non-secret provider settings persisted under `settings.ai.provider`.
* The API key is intentionally absent here. * The API key is intentionally absent here.
@@ -23,6 +32,9 @@ export const STT_API_STYLES: SttApiStyle[] = ['multipart', 'json'];
export interface AiProviderSettings { export interface AiProviderSettings {
driver: AiDriver; driver: AiDriver;
chatModel: string; chatModel: string;
// Chat provider implementation for the `openai` driver. Unset → defaults to
// 'openai-compatible' (so reasoning is surfaced by default). See ChatApiStyle.
chatApiStyle?: ChatApiStyle;
embeddingModel?: string; embeddingModel?: string;
baseUrl?: string; baseUrl?: string;
// Embedding-specific base URL. Falls back to `baseUrl` when empty/unset. // Embedding-specific base URL. Falls back to `baseUrl` when empty/unset.
@@ -76,6 +88,7 @@ export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
export interface MaskedAiSettings { export interface MaskedAiSettings {
driver?: AiDriver; driver?: AiDriver;
chatModel?: string; chatModel?: string;
chatApiStyle?: ChatApiStyle;
embeddingModel?: string; embeddingModel?: string;
baseUrl?: string; baseUrl?: string;
embeddingBaseUrl?: string; embeddingBaseUrl?: string;

View File

@@ -1,5 +1,12 @@
import { IsIn, IsOptional, IsString } from 'class-validator'; import { IsIn, IsOptional, IsString } from 'class-validator';
import { AI_DRIVERS, AiDriver, STT_API_STYLES, SttApiStyle } from '../ai.types'; import {
AI_DRIVERS,
AiDriver,
CHAT_API_STYLES,
ChatApiStyle,
STT_API_STYLES,
SttApiStyle,
} from '../ai.types';
/** /**
* Admin update payload for the workspace AI provider settings. * Admin update payload for the workspace AI provider settings.
@@ -18,6 +25,10 @@ export class UpdateAiSettingsDto {
@IsString() @IsString()
chatModel?: string; chatModel?: string;
@IsOptional()
@IsIn(CHAT_API_STYLES)
chatApiStyle?: ChatApiStyle;
@IsOptional() @IsOptional()
@IsString() @IsString()
embeddingModel?: string; embeddingModel?: string;