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:
@@ -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)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (официальный)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user