diff --git a/apps/server/src/integrations/ai/ai.service.spec.ts b/apps/server/src/integrations/ai/ai.service.spec.ts index ef44a59d..ae9d3770 100644 --- a/apps/server/src/integrations/ai/ai.service.spec.ts +++ b/apps/server/src/integrations/ai/ai.service.spec.ts @@ -285,3 +285,43 @@ describe('AiService.getChatModel role model override', () => { ); }); }); + +/** + * Provider selection for the `openai` driver (reasoning surfacing). A custom + * baseURL means an openai-COMPATIBLE third-party endpoint (z.ai/GLM, DeepSeek, + * ...): we must use @ai-sdk/openai-compatible, which maps the streamed + * `reasoning_content` to reasoning parts (the official @ai-sdk/openai provider + * drops it). Real OpenAI (no baseURL) keeps the official provider. We assert via + * the built model's `.provider` tag. + */ +describe('AiService.getChatModel openai provider selection', () => { + function serviceWith(baseUrl: string | undefined) { + const aiSettings = { + resolve: jest.fn().mockResolvedValue({ + driver: 'openai', + chatModel: 'glm-5.2', + apiKey: 'key', + baseUrl, + }), + }; + return new AiService( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + aiSettings as any, + { find: jest.fn() } as any, + { decryptSecret: jest.fn() } as any, + ); + } + + it('uses the openai-compatible provider when a custom baseURL is set', async () => { + const model = await serviceWith('https://api.z.ai/api/coding/paas/v4').getChatModel( + 'ws-1', + ); + // openai-compatible surfaces reasoning_content; tagged "openai-compatible.*". + expect((model as { provider: string }).provider).toContain('openai-compatible'); + }); + + it('uses the official openai provider when there is no baseURL (real OpenAI)', async () => { + const model = await serviceWith(undefined).getChatModel('ws-1'); + expect((model as { provider: string }).provider).toBe('openai.chat'); + }); +}); diff --git a/apps/server/src/integrations/ai/ai.service.ts b/apps/server/src/integrations/ai/ai.service.ts index 4f72d23b..46134aed 100644 --- a/apps/server/src/integrations/ai/ai.service.ts +++ b/apps/server/src/integrations/ai/ai.service.ts @@ -7,6 +7,7 @@ import { type LanguageModel, } from 'ai'; import { createOpenAI } from '@ai-sdk/openai'; +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { createOllama } from 'ai-sdk-ollama'; import { AiSettingsService } from './ai-settings.service'; @@ -143,17 +144,29 @@ export class AiService { switch (driver) { case 'openai': - // baseURL (when set) covers openai-compatible endpoints. Use Chat - // Completions (/chat/completions) — the portable OpenAI-compatible - // endpoint. The default callable createOpenAI(...)(model) targets the - // Responses API (/responses), which OpenAI-compatible gateways - // (OpenRouter, etc.) reject on multi-turn requests (history with - // assistant messages) → 400. - // DIAGNOSTIC (provider ECONNRESET investigation) — temporary: pass the - // passive instrumented fetch (logging only; no behavior change). + // A custom baseURL means an openai-COMPATIBLE third-party endpoint + // (z.ai / GLM, DeepSeek, OpenRouter, ...). Use @ai-sdk/openai-compatible + // there: unlike the official @ai-sdk/openai provider, it maps the + // provider's streamed `reasoning_content` to reasoning parts, so the + // agent's chain-of-thought is surfaced to the UI (and the model is not + // silent during a long server-side "thinking" phase). It also targets + // Chat Completions (/chat/completions), the portable endpoint that + // OpenAI-compatible gateways accept on multi-turn history (the official + // provider's default callable targets /responses, which they 400). + if (baseUrl) { + return createOpenAICompatible({ + name: 'openai-compatible', + apiKey, + baseURL: baseUrl, + // Passive ECONNRESET telemetry; on the chat path it also carries the + // streaming fetch (disabled long-turn timeouts) once #175 lands. + fetch: this.aiDiagnosticFetch, + })(chatModel); + } + // Real OpenAI (no custom baseURL): keep the official provider, on Chat + // Completions to preserve multi-turn compatibility. return createOpenAI({ apiKey, - baseURL: baseUrl, fetch: this.aiDiagnosticFetch, }).chat(chatModel); case 'gemini':