Files
gitmost/apps/client/src/features/workspace/services/ai-settings-service.ts
claude code agent 227 59190148db 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>
2026-06-24 22:58:15 +03:00

114 lines
4.0 KiB
TypeScript

import api from "@/lib/api-client";
// Supported LLM providers/drivers.
export type AiDriver = "openai" | "gemini" | "ollama";
// How STT (speech-to-text) requests are encoded for the transcription endpoint.
// - 'multipart' -> OpenAI-compatible multipart/form-data (OpenAI, speaches,
// faster-whisper-server)
// - 'json' -> JSON body with base64-encoded audio (OpenRouter)
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.
// No API key is ever returned; only `hasApiKey` / `hasEmbeddingApiKey` indicate
// whether one is stored. `embeddingBaseUrl` is the RAW stored value (empty means
// "uses the chat base URL").
export interface IAiSettings {
driver?: AiDriver;
chatModel?: string;
chatApiStyle?: ChatApiStyle;
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty =
// built-in locked persona.
publicShareAssistantRoleId?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;
systemPrompt?: string;
hasApiKey: boolean;
hasEmbeddingApiKey: boolean;
// STT-specific settings. `sttBaseUrl` is the RAW stored value (empty means
// "uses the chat base URL"). `hasSttApiKey` indicates whether an STT-specific
// key is stored (empty means "uses the chat API key").
sttModel?: string;
sttBaseUrl?: string;
sttApiStyle?: SttApiStyle;
// ISO-639-1 dictation language; empty = auto-detect.
sttLanguage?: string;
hasSttApiKey: boolean;
// RAG indexing coverage (pages indexed for semantic search).
indexedPages: number;
totalPages: number;
}
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
// - omit the key -> key unchanged
// - `key: ''` -> clear the stored key
// - `key: 'non-empty'` -> set the key
// Non-secret fields are saved as given.
export interface IAiSettingsUpdate {
driver?: AiDriver;
chatModel?: string;
chatApiStyle?: ChatApiStyle;
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty =
// built-in locked persona.
publicShareAssistantRoleId?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;
systemPrompt?: string;
apiKey?: string;
embeddingApiKey?: string;
sttModel?: string;
sttBaseUrl?: string;
sttApiStyle?: SttApiStyle;
// ISO-639-1 dictation language; empty = auto-detect.
sttLanguage?: string;
// Write-only STT key (same semantics as `apiKey` / `embeddingApiKey`).
sttApiKey?: string;
}
// Result of a connection test against the configured provider.
// The error string is already sanitized server-side.
export interface IAiTestResult {
ok: boolean;
error?: string;
}
// Which endpoint a connection test probes.
export type AiTestCapability = "chat" | "embeddings" | "stt";
export async function getAiSettings(): Promise<IAiSettings> {
const req = await api.post<IAiSettings>("/workspace/ai-settings");
return req.data;
}
export async function updateAiSettings(
data: IAiSettingsUpdate,
): Promise<IAiSettings> {
const req = await api.post<IAiSettings>("/workspace/ai-settings/update", data);
return req.data;
}
export async function testAiConnection(
capability: AiTestCapability,
): Promise<IAiTestResult> {
const req = await api.post<IAiTestResult>("/workspace/ai-settings/test", {
capability,
});
return req.data;
}
export async function reindexAiEmbeddings(): Promise<IAiSettings> {
const req = await api.post<IAiSettings>("/workspace/ai-settings/reindex");
return req.data;
}