Merge gitea/develop into feat/public-share-assistant

Resolve conflicts with the independently-merged ai-agent-roles feature:
- ai-chat.module.ts: keep BOTH AiAgentRolesModule and the public-share
  wiring (Share/Search modules, PublicShareChatController, services).
- ai.service.ts: take develop's getChatModel ChatModelOverride superset,
  which already covers the public-share model-id-only override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
vvzvlad
2026-06-20 18:40:58 +03:00
70 changed files with 4028 additions and 1847 deletions

View File

@@ -0,0 +1,61 @@
import { describeProviderError } from './ai-error.util';
/**
* Unit tests for describeProviderError: the shared formatter used both for the
* server log line and for the error text streamed back to the client. This
* pins the behaviour, including the one behaviour change introduced when the
* two inline formatters were unified: a truncated, single-line snippet of the
* provider `responseBody`/`text` is appended (so a misconfigured endpoint's
* HTML error page is diagnosable). The util guarantees the API key is never in
* the response body, so this is safe to surface.
*/
describe('describeProviderError', () => {
it('uses the fallback for a null/empty/undefined error', () => {
expect(describeProviderError(null, 'AI stream error')).toBe(
'AI stream error',
);
expect(describeProviderError('', 'AI stream error')).toBe('AI stream error');
expect(describeProviderError(undefined)).toBe('Unknown error');
});
it('returns a non-empty plain string error as-is', () => {
expect(describeProviderError('boom')).toBe('boom');
});
it('formats statusCode + message', () => {
expect(
describeProviderError({ statusCode: 401, message: 'Unauthorized' }),
).toBe('401: Unauthorized');
});
it('falls back to message when there is no statusCode', () => {
expect(describeProviderError({ message: 'nope' })).toBe('nope');
});
it('appends a whitespace-collapsed response body snippet', () => {
const out = describeProviderError({
statusCode: 502,
message: 'Bad Gateway',
responseBody: '<html>\n <body>upstream error</body>\n</html>',
});
expect(out.startsWith('502: Bad Gateway | response body: ')).toBe(true);
// Newlines and runs of spaces are collapsed to single spaces.
expect(out).toContain('<html> <body>upstream error</body> </html>');
});
it('reads `text` when responseBody is absent', () => {
expect(describeProviderError({ message: 'e', text: 'body-text' })).toBe(
'e | response body: body-text',
);
});
it('truncates a long body to 300 chars + ellipsis', () => {
const out = describeProviderError({
message: 'e',
responseBody: 'x'.repeat(500),
});
expect(out).toContain('…');
// 'e | response body: ' + 300 chars + '…'
expect(out.length).toBeLessThan('e | response body: '.length + 305);
});
});

View File

@@ -9,10 +9,16 @@
*
* None of these fields contain the API key (it is sent as an Authorization
* header and never echoed in the response body), so this is safe to log/return.
*
* `fallback` is used when the error carries no usable message (e.g. a bare
* object); defaults to 'Unknown error'.
*/
export function describeProviderError(err: unknown): string {
export function describeProviderError(
err: unknown,
fallback = 'Unknown error',
): string {
if (typeof err !== 'object' || err === null) {
return typeof err === 'string' ? err : 'Unknown error';
return typeof err === 'string' && err ? err : fallback;
}
const e = err as {
statusCode?: number;
@@ -23,7 +29,7 @@ export function describeProviderError(err: unknown): string {
const base =
typeof e.statusCode === 'number'
? `${e.statusCode}: ${e.message ?? ''}`.trim()
: (e.message ?? 'Unknown error');
: (e.message ?? fallback);
const body = (e.responseBody ?? e.text ?? '').trim();
if (!body) return base;
// Collapse whitespace so a multi-line HTML body stays on one log line.

View File

@@ -5,7 +5,7 @@ import { ServiceUnavailableException } from '@nestjs/common';
* driver / chat model / API key). Maps to HTTP 503 (§6.2/§6.4).
*/
export class AiNotConfiguredException extends ServiceUnavailableException {
constructor() {
super('AI provider not configured');
constructor(message = 'AI provider not configured') {
super(message);
}
}

View File

@@ -0,0 +1,174 @@
import { AiService } from './ai.service';
import { AiNotConfiguredException } from './ai-not-configured.exception';
/**
* Unit test for the role model-override 503 path of AiService.getChatModel.
*
* AiService's constructor body is trivial (it only stores its deps), so it can
* be unit-constructed with stubbed collaborators — no Nest module graph, which
* the src-rooted jest setup cannot fully resolve for the heavier specs. We stub:
* - aiSettings.resolve -> a workspace configured for openai (so cfg.driver is
* set and we pass the first guard),
* - aiProviderCredentialsRepo.find -> undefined (the override driver has NO
* configured credentials),
* - secretBox -> unused on this path (no creds to decrypt).
*
* With a role override pointing at a DIFFERENT driver ('gemini') that has no
* creds, getChatModel must throw AiNotConfiguredException (503) and the message
* must name the override driver (and the role) so an admin can fix it.
*/
describe('AiService.getChatModel role model override', () => {
function makeService(opts: {
workspaceDriver: string;
credsApiKeyEnc?: string;
}) {
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: opts.workspaceDriver,
chatModel: 'gpt-4o-mini',
apiKey: 'workspace-key',
baseUrl: undefined,
}),
};
const aiProviderCredentialsRepo = {
find: jest.fn().mockResolvedValue(
opts.credsApiKeyEnc ? { apiKeyEnc: opts.credsApiKeyEnc } : undefined,
),
};
const secretBox = {
decryptSecret: jest.fn().mockReturnValue('decrypted'),
};
const service = new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiProviderCredentialsRepo as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secretBox as any,
);
return { service, aiSettings, aiProviderCredentialsRepo, secretBox };
}
it('throws AiNotConfiguredException (503) naming the override driver when its creds are missing', async () => {
const { service, aiProviderCredentialsRepo } = makeService({
workspaceDriver: 'openai',
});
await expect(
service.getChatModel('ws-1', {
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
roleName: 'Researcher',
}),
).rejects.toBeInstanceOf(AiNotConfiguredException);
// Re-run to assert the message names the driver (and role) for the admin.
await service
.getChatModel('ws-1', {
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
roleName: 'Researcher',
})
.then(
() => {
throw new Error('expected getChatModel to throw');
},
(err: unknown) => {
expect(err).toBeInstanceOf(AiNotConfiguredException);
const message = (err as AiNotConfiguredException).message;
expect(message).toContain('gemini');
expect(message).toContain('Researcher');
},
);
// The override driver's creds were looked up for the right driver.
expect(aiProviderCredentialsRepo.find).toHaveBeenCalledWith('ws-1', 'gemini');
});
it('cross-driver override with creds present: resolves without throwing, using the OVERRIDE driver creds', async () => {
// Workspace driver is openai; the role overrides to gemini, which HAS creds.
const { service, aiProviderCredentialsRepo, secretBox } = makeService({
workspaceDriver: 'openai',
credsApiKeyEnc: 'enc-gemini-key',
});
const model = await service.getChatModel('ws-1', {
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
roleName: 'Researcher',
});
// A real LanguageModel was built (no 503).
expect(model).toBeDefined();
// Creds were fetched for the OVERRIDE driver, then decrypted.
expect(aiProviderCredentialsRepo.find).toHaveBeenCalledWith('ws-1', 'gemini');
expect(secretBox.decryptSecret).toHaveBeenCalledWith('enc-gemini-key');
});
it('cross-driver override to ollama (workspace driver != ollama): throws 503, does NOT silently reuse the workspace baseUrl', async () => {
// Workspace driver is openai with a configured (gateway) baseUrl. A role that
// overrides to ollama has no dedicated ollama endpoint, so pointing the
// ollama client at the workspace's openai baseUrl would be wrong — it must
// fail explicitly instead.
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: 'openai',
chatModel: 'gpt-4o-mini',
apiKey: 'workspace-key',
baseUrl: 'https://openrouter.example/v1',
}),
};
const aiProviderCredentialsRepo = { find: jest.fn() };
const secretBox = { decryptSecret: jest.fn() };
const service = new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiProviderCredentialsRepo as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secretBox as any,
);
await service
.getChatModel('ws-1', {
driver: 'ollama',
chatModel: 'llama3',
roleName: 'Local',
})
.then(
() => {
throw new Error('expected getChatModel to throw');
},
(err: unknown) => {
expect(err).toBeInstanceOf(AiNotConfiguredException);
const message = (err as AiNotConfiguredException).message;
// Names the role and the workspace driver, and mentions ollama.
expect(message).toContain('ollama');
expect(message).toContain('openai');
expect(message).toContain('Local');
// Must NOT leak / reuse the workspace gateway baseUrl in the path.
expect(message).not.toContain('openrouter.example');
},
);
// No ollama creds lookup happens (ollama needs no key); we fail before that.
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
});
it('chatModel-only override (no driver): reuses the workspace driver+creds, no creds lookup/decrypt', async () => {
// No override.driver => the workspace openai driver + its apiKey are reused;
// ai_provider_credentials must NOT be queried and nothing is decrypted.
const { service, aiProviderCredentialsRepo, secretBox } = makeService({
workspaceDriver: 'openai',
});
const model = await service.getChatModel('ws-1', {
chatModel: 'gpt-4o',
roleName: 'Writer',
});
expect(model).toBeDefined();
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
});
});

View File

@@ -14,6 +14,22 @@ import { AiNotConfiguredException } from './ai-not-configured.exception';
import { AiEmbeddingNotConfiguredException } from './ai-embedding-not-configured.exception';
import { AiSttNotConfiguredException } from './ai-stt-not-configured.exception';
import { describeProviderError } from './ai-error.util';
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
import { SecretBoxService } from '../crypto/secret-box';
import { AiDriver } from './ai.types';
/**
* Optional chat-model override carried by an agent role (`ai_agent_roles.
* model_config`). `chatModel` swaps the model id; `driver` (optional) switches
* the whole provider, in which case its creds come from `ai_provider_credentials`
* for that driver. `roleName` is only used to produce a clear 503 message when
* the chosen driver is not configured.
*/
export interface ChatModelOverride {
driver?: AiDriver;
chatModel?: string;
roleName?: string;
}
/**
* Builds AI SDK language models from per-workspace config and runs cheap
@@ -27,39 +43,96 @@ import { describeProviderError } from './ai-error.util';
export class AiService {
private readonly logger = new Logger(AiService.name);
constructor(private readonly aiSettings: AiSettingsService) {}
constructor(
private readonly aiSettings: AiSettingsService,
private readonly aiProviderCredentialsRepo: AiProviderCredentialsRepo,
private readonly secretBox: SecretBoxService,
) {}
/**
* Resolve the workspace config and build the chat language model.
* Throws AiNotConfiguredException (→ 503) when the config is incomplete.
*
* `override.chatModel` substitutes ONLY the model id; the driver, baseUrl and
* apiKey are ALWAYS reused from the workspace's configured chat provider (the
* override is not an isolated provider/key). The public-share assistant uses
* this to run the cheap `publicShareChatModel` on the SAME provider. An
* empty/blank override falls back to the workspace `chatModel`.
* `override` optionally swaps the model id and/or the whole provider:
* - `override.chatModel` replaces the workspace chat model id;
* - `override.driver` (when it differs from the workspace driver) switches the
* provider, pulling that driver's creds from `ai_provider_credentials`. When
* those creds are missing the call throws a 503 naming the role's driver — a
* deliberate, explicit failure rather than a silent fallback. Resolved
* BEFORE the stream starts so the 503 surfaces as clean JSON.
*
* Two callers: an agent role's `model_config` (may set driver + model), and
* the anonymous public-share assistant, which passes ONLY `chatModel` (the
* cheap `publicShareChatModel`) so the driver/baseUrl/apiKey stay the
* workspace's configured chat provider. A blank override falls back to the
* workspace `chatModel`.
*/
async getChatModel(
workspaceId: string,
override?: { chatModel?: string },
override?: ChatModelOverride,
): Promise<LanguageModel> {
const cfg = await this.aiSettings.resolve(workspaceId);
if (
!cfg?.driver ||
!cfg?.chatModel ||
(cfg.driver !== 'ollama' && !cfg.apiKey)
) {
if (!cfg?.driver) {
throw new AiNotConfiguredException();
}
// Effective model id: a non-blank override, else the workspace chatModel.
const overrideModel =
typeof override?.chatModel === 'string' && override.chatModel.trim()
? override.chatModel.trim()
: undefined;
const modelId = overrideModel ?? cfg.chatModel;
// Determine the effective driver + model + creds, applying the override.
const overrideDriver = override?.driver;
const driver: AiDriver = overrideDriver ?? cfg.driver;
const chatModel = override?.chatModel?.trim() || cfg.chatModel;
switch (cfg.driver) {
let apiKey = cfg.apiKey;
let baseUrl = cfg.baseUrl;
// A driver override that differs from the workspace driver needs that
// driver's own creds (the workspace driver's key would be wrong/absent).
if (overrideDriver && overrideDriver !== cfg.driver) {
if (overrideDriver === 'ollama') {
// Cross-driver override to ollama: the workspace driver is NOT ollama, so
// there is no configured ollama endpoint. `cfg.baseUrl` belongs to the
// workspace driver (e.g. an OpenAI/OpenRouter gateway) and pointing the
// ollama client at it would silently send requests to the wrong server.
// Fail explicitly (503) — a dedicated per-driver ollama endpoint is not
// supported yet. The same-driver ollama case (handled outside this block)
// legitimately reuses the workspace's ollama endpoint and is unaffected.
const who = override?.roleName ? ` for role "${override.roleName}"` : '';
throw new AiNotConfiguredException(
`An ollama model override${who} requires a dedicated ollama endpoint, ` +
`which is not supported when the workspace driver is "${cfg.driver}". ` +
`Set the role's driver to "${cfg.driver}" or switch the workspace ` +
`to ollama.`,
);
} else {
const creds = await this.aiProviderCredentialsRepo.find(
workspaceId,
overrideDriver,
);
apiKey = creds?.apiKeyEnc
? this.secretBox.decryptSecret(creds.apiKeyEnc)
: undefined;
if (!apiKey) {
// Explicit 503: the role chose a provider that is not set up. Name the
// driver (and role, when known) so the admin can fix it — no silent
// fallback to the workspace model (error-handling convention).
const who = override?.roleName ? ` for role "${override.roleName}"` : '';
throw new AiNotConfiguredException(
`The model provider "${overrideDriver}"${who} is selected but not ` +
`configured (no API key). Configure ${overrideDriver} in AI ` +
`settings or change the role's model.`,
);
}
// A cross-driver override does not carry the workspace baseUrl (that URL
// belongs to the workspace driver); use the provider default for the
// overridden driver.
baseUrl = undefined;
}
}
if (!chatModel || (driver !== 'ollama' && !apiKey)) {
throw new AiNotConfiguredException();
}
switch (driver) {
case 'openai':
// baseURL (when set) covers openai-compatible endpoints. Use Chat
// Completions (/chat/completions) — the portable OpenAI-compatible
@@ -67,14 +140,12 @@ export class AiService {
// Responses API (/responses), which OpenAI-compatible gateways
// (OpenRouter, etc.) reject on multi-turn requests (history with
// assistant messages) → 400.
return createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseUrl }).chat(
modelId,
);
return createOpenAI({ apiKey, baseURL: baseUrl }).chat(chatModel);
case 'gemini':
return createGoogleGenerativeAI({ apiKey: cfg.apiKey })(modelId);
return createGoogleGenerativeAI({ apiKey })(chatModel);
case 'ollama':
// Ollama needs no API key.
return createOllama({ baseURL: cfg.baseUrl })(modelId);
return createOllama({ baseURL: baseUrl })(chatModel);
default:
throw new AiNotConfiguredException();
}

View File

@@ -0,0 +1,77 @@
import { SecretBoxService } from './secret-box';
import { EnvironmentService } from '../environment/environment.service';
/**
* Unit tests for SecretBoxService: the AES-256-GCM helper that protects provider
* API keys at rest. The contract is: encrypt -> decrypt round-trips the input;
* two encryptions of the same input yield different blobs (random salt+iv) yet
* both decrypt; a tampered blob or a different APP_SECRET fails decryption with
* the recoverable "APP_SECRET may have changed" message the UI relies on.
*/
describe('SecretBoxService', () => {
// Construct a SecretBoxService whose EnvironmentService.getAppSecret returns a
// fixed 64-hex secret. Only getAppSecret is exercised, so a thin fake suffices.
function makeBox(appSecret: string): SecretBoxService {
const env = {
getAppSecret: () => appSecret,
} as unknown as EnvironmentService;
return new SecretBoxService(env);
}
const SECRET_A =
'00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
const SECRET_B =
'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100';
it('round-trips: decrypt(encrypt(x)) === x', () => {
const box = makeBox(SECRET_A);
const plain = 'sk-super-secret-provider-key-12345';
const blob = box.encryptSecret(plain);
expect(box.decryptSecret(blob)).toBe(plain);
});
it('produces a different blob each time, both of which decrypt', () => {
const box = makeBox(SECRET_A);
const plain = 'identical-input';
const blob1 = box.encryptSecret(plain);
const blob2 = box.encryptSecret(plain);
// Random per-record salt + iv => the ciphertext blobs must differ.
expect(blob1).not.toBe(blob2);
expect(box.decryptSecret(blob1)).toBe(plain);
expect(box.decryptSecret(blob2)).toBe(plain);
});
it('throws the recoverable error on a tampered auth tag', () => {
const box = makeBox(SECRET_A);
const blob = box.encryptSecret('tamper-me');
// Layout: base64( salt[16] | iv[12] | authTag[16] | ciphertext ). Flip a bit
// in the auth-tag region so GCM verification (decipher.final) rejects it.
const data = Buffer.from(blob, 'base64');
const authTagByteIndex = 16 + 12; // first byte of the auth tag
data[authTagByteIndex] = data[authTagByteIndex] ^ 0xff;
const tampered = data.toString('base64');
expect(() => box.decryptSecret(tampered)).toThrow(/APP_SECRET may have changed/);
});
it('throws the recoverable error on a tampered ciphertext byte', () => {
const box = makeBox(SECRET_A);
const blob = box.encryptSecret('tamper-the-body');
const data = Buffer.from(blob, 'base64');
// Last byte is part of the ciphertext; flipping it must fail GCM auth.
data[data.length - 1] = data[data.length - 1] ^ 0xff;
const tampered = data.toString('base64');
expect(() => box.decryptSecret(tampered)).toThrow(/APP_SECRET may have changed/);
});
it('throws when decrypting under a different APP_SECRET', () => {
const boxA = makeBox(SECRET_A);
const boxB = makeBox(SECRET_B);
const blob = boxA.encryptSecret('rotate-me');
// A different APP_SECRET derives a different scrypt key => GCM auth fails.
expect(() => boxB.decryptSecret(blob)).toThrow(/APP_SECRET may have changed/);
});
});

View File

@@ -214,6 +214,13 @@ export class EnvironmentService {
return !this.isCloud();
}
isCompactPageTreeEnabled(): boolean {
const compactTree = this.configService
.get<string>('COMPACT_PAGE_TREE', 'true')
.toLowerCase();
return compactTree === 'true';
}
getStripePublishableKey(): string {
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
}

View File

@@ -35,6 +35,7 @@ export class StaticModule implements OnModuleInit {
ENV: this.environmentService.getNodeEnv(),
APP_URL: this.environmentService.getAppUrl(),
CLOUD: this.environmentService.isCloud(),
COMPACT_PAGE_TREE: this.environmentService.isCompactPageTreeEnabled(),
FILE_UPLOAD_SIZE_LIMIT:
this.environmentService.getFileUploadSizeLimit(),
FILE_IMPORT_SIZE_LIMIT: