bazis 0.138.0 → 0.140.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/core/access/Access.module.ts +6 -1
- package/core/access/catalog/AccessCatalog.ts +56 -7
- package/core/access/catalog/IAccessCatalog.service.ts +28 -2
- package/core/access/catalog/PermissionSourceLoader.ts +43 -0
- package/core/access/catalog/PermissionSources.ts +222 -0
- package/core/access/catalog/moduleFields.ts +60 -6
- package/core/access/catalog/permissions.ts +46 -1
- package/core/access/check/Access.ts +55 -28
- package/core/access/check/accessConfig.ts +9 -1
- package/core/access/check/can.ts +2 -2
- package/core/access/check/conditions.ts +69 -11
- package/core/access/check/decimal.ts +61 -0
- package/core/access/check/filter.ts +39 -8
- package/core/access/contracts/PermissionSource.ts +32 -0
- package/core/access/contracts/types.ts +12 -2
- package/core/access/generated/deps.ts +5 -1
- package/core/access/http/AccessCatalog.controller.ts +9 -2
- package/core/access/index.ts +12 -1
- package/core/agent/AgentContextBuilder.ts +119 -16
- package/core/agent/AgentRuntime.ts +8 -0
- package/core/agent/AgentToolExecutor.ts +7 -0
- package/core/agent/ModelProvider.ts +6 -0
- package/core/agent/README.md +35 -3
- package/core/agent/errors.ts +32 -0
- package/core/agent/index.ts +7 -0
- package/core/agent/internal/AgentExecutionDriver.ts +598 -24
- package/core/agent/internal/AgentExecutionState.ts +26 -3
- package/core/agent/semantic.ts +60 -1
- package/core/cache/SPEC.md +3 -3
- package/core/di/README.md +10 -3
- package/core/infra/anthropic.ts +11 -0
- package/core/infra/connectors/anthropic.ts +440 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/llm.ts +12 -0
- package/core/infra/connectors/llmRouter.ts +12 -0
- package/core/infra/connectors/openaiCompatible.ts +128 -63
- package/core/infra/connectors/openaiTextStream.ts +5 -2
- package/core/infra/connectors/toolNames.ts +49 -0
- package/core/scripts/di-generate.ts +34 -4
- package/core/websocket/DESIGN.md +2 -2
- package/index.ts +5 -0
- package/library/orm/Repository/README.md +14 -11
- package/package.json +7 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AgentModelContextOverflowError,
|
|
2
3
|
AgentModelTimeoutError,
|
|
4
|
+
AgentModelUnavailableError,
|
|
3
5
|
agentMessage,
|
|
4
6
|
agentModelResponse,
|
|
5
7
|
agentToolCall,
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
type LlmProviderAdapter,
|
|
23
25
|
} from "./llm";
|
|
24
26
|
import { readOpenAiTextStream } from "./openaiTextStream";
|
|
27
|
+
import { providerToolName, toolNameMapping, type ToolNameMapping } from "./toolNames";
|
|
25
28
|
import { InfraError } from "../InfraConnector";
|
|
26
29
|
import { redactSensitiveText } from "../../../library/redaction";
|
|
27
30
|
|
|
@@ -52,9 +55,14 @@ export class OpenAiCompatibleProviderError extends Error {
|
|
|
52
55
|
|
|
53
56
|
type OpenAiChatRole = "system" | "user" | "assistant" | "tool";
|
|
54
57
|
|
|
58
|
+
type OpenAiContentPart =
|
|
59
|
+
| { readonly type: "text"; readonly text: string }
|
|
60
|
+
| { readonly type: "image_url"; readonly image_url: { readonly url: string; readonly detail: "auto" | "low" | "high" } }
|
|
61
|
+
| { readonly type: "file"; readonly file: { readonly file_data: string; readonly filename: string } };
|
|
62
|
+
|
|
55
63
|
interface OpenAiChatMessage {
|
|
56
64
|
readonly role: OpenAiChatRole;
|
|
57
|
-
readonly content?: string | null;
|
|
65
|
+
readonly content?: string | readonly OpenAiContentPart[] | null;
|
|
58
66
|
readonly tool_call_id?: string;
|
|
59
67
|
readonly tool_calls?: readonly OpenAiToolCall[];
|
|
60
68
|
}
|
|
@@ -92,17 +100,14 @@ interface OpenAiChatResponse {
|
|
|
92
100
|
readonly prompt_tokens?: number;
|
|
93
101
|
readonly completion_tokens?: number;
|
|
94
102
|
readonly total_tokens?: number;
|
|
103
|
+
readonly prompt_tokens_details?: { readonly cached_tokens?: number } | null;
|
|
104
|
+
readonly completion_tokens_details?: { readonly reasoning_tokens?: number } | null;
|
|
95
105
|
};
|
|
96
106
|
readonly error?: {
|
|
97
107
|
readonly message?: string;
|
|
98
108
|
};
|
|
99
109
|
}
|
|
100
110
|
|
|
101
|
-
interface ToolNameMapping {
|
|
102
|
-
readonly providerToBazis: ReadonlyMap<string, string>;
|
|
103
|
-
readonly bazisToProvider: ReadonlyMap<string, string>;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
111
|
function stripTrailingSlash(value: string): string {
|
|
107
112
|
return value.replace(/\/+$/, "");
|
|
108
113
|
}
|
|
@@ -126,47 +131,6 @@ function cancelBodyBestEffort(body: ReadableStream<Uint8Array> | null | undefine
|
|
|
126
131
|
void body.cancel(reason).catch(() => undefined);
|
|
127
132
|
}
|
|
128
133
|
|
|
129
|
-
function shortHash(value: string): string {
|
|
130
|
-
let hash = 2166136261;
|
|
131
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
132
|
-
hash ^= value.charCodeAt(index);
|
|
133
|
-
hash = Math.imul(hash, 16777619);
|
|
134
|
-
}
|
|
135
|
-
return (hash >>> 0).toString(36);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function providerToolName(name: string, used: Set<string>): string {
|
|
139
|
-
const replaced = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
140
|
-
const prefixed = /^[a-zA-Z_]/.test(replaced) ? replaced : `tool_${replaced}`;
|
|
141
|
-
const trimmed = prefixed.length <= 64 ? prefixed : `${prefixed.slice(0, 55)}_${shortHash(name)}`;
|
|
142
|
-
if (!used.has(trimmed)) {
|
|
143
|
-
used.add(trimmed);
|
|
144
|
-
return trimmed;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const suffix = shortHash(name);
|
|
148
|
-
const base = trimmed.slice(0, Math.max(1, 63 - suffix.length));
|
|
149
|
-
const unique = `${base}_${suffix}`;
|
|
150
|
-
if (used.has(unique)) {
|
|
151
|
-
throw new OpenAiCompatibleProviderError(`Tool name "${name}" collides after OpenAI-compatible normalization.`);
|
|
152
|
-
}
|
|
153
|
-
used.add(unique);
|
|
154
|
-
return unique;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function toolNameMapping(tools: readonly AgentToolContract[]): ToolNameMapping {
|
|
158
|
-
const providerToBazis = new Map<string, string>();
|
|
159
|
-
const bazisToProvider = new Map<string, string>();
|
|
160
|
-
const used = new Set<string>();
|
|
161
|
-
for (let index = 0; index < tools.length; index += 1) {
|
|
162
|
-
const tool = tools[index] as AgentToolContract;
|
|
163
|
-
const providerName = providerToolName(tool.name, used);
|
|
164
|
-
providerToBazis.set(providerName, tool.name);
|
|
165
|
-
bazisToProvider.set(tool.name, providerName);
|
|
166
|
-
}
|
|
167
|
-
return Object.freeze({ providerToBazis, bazisToProvider });
|
|
168
|
-
}
|
|
169
|
-
|
|
170
134
|
function schemaForTool(tool: AgentToolContract): JsonObject {
|
|
171
135
|
if (tool.input?.kind === "json-schema") {
|
|
172
136
|
return tool.input.schema;
|
|
@@ -227,6 +191,28 @@ function contentText(parts: readonly AgentContentPart[]): string {
|
|
|
227
191
|
return lines.join("\n");
|
|
228
192
|
}
|
|
229
193
|
|
|
194
|
+
/**
|
|
195
|
+
* What a user sends as the provider expects it: one string for words, parts
|
|
196
|
+
* when there is an image or a file whose bytes are in the dialog. A file given
|
|
197
|
+
* by address is named in the text: Chat Completions takes file content only.
|
|
198
|
+
*/
|
|
199
|
+
function userContent(parts: readonly AgentContentPart[]): string | readonly OpenAiContentPart[] {
|
|
200
|
+
const inline = (part: AgentContentPart): boolean => part.kind === "image" || (part.kind === "file" && part.uri.startsWith("data:"));
|
|
201
|
+
if (!parts.some(inline)) return contentText(parts);
|
|
202
|
+
const out: OpenAiContentPart[] = [];
|
|
203
|
+
for (const part of parts) {
|
|
204
|
+
if (part.kind === "image") {
|
|
205
|
+
out.push({ type: "image_url", image_url: { url: part.uri, detail: part.detail } });
|
|
206
|
+
} else if (part.kind === "file" && part.uri.startsWith("data:")) {
|
|
207
|
+
out.push({ type: "file", file: { file_data: part.uri, filename: part.name ?? "file" } });
|
|
208
|
+
} else {
|
|
209
|
+
const text = contentText([part]);
|
|
210
|
+
if (text.length > 0) out.push({ type: "text", text });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return Object.freeze(out);
|
|
214
|
+
}
|
|
215
|
+
|
|
230
216
|
function toOpenAiRole(role: AgentMessage["role"]): OpenAiChatRole {
|
|
231
217
|
if (role === "developer") {
|
|
232
218
|
return "system";
|
|
@@ -266,17 +252,75 @@ function finishReasonOf(reason: string | undefined, hasToolCalls: boolean): Agen
|
|
|
266
252
|
return "error";
|
|
267
253
|
}
|
|
268
254
|
|
|
269
|
-
|
|
255
|
+
/** Arguments the model wrote: the value, or why they could not be read. */
|
|
256
|
+
function parseToolArguments(raw: string, callId: string): { readonly input: JsonValue } | { readonly inputError: string } {
|
|
270
257
|
if (raw.trim().length === 0) {
|
|
271
|
-
return Object.freeze({});
|
|
258
|
+
return { input: Object.freeze({}) };
|
|
272
259
|
}
|
|
273
260
|
try {
|
|
274
|
-
return normalizeJsonValue(JSON.parse(raw), `toolCall.${callId}.arguments`);
|
|
261
|
+
return { input: normalizeJsonValue(JSON.parse(raw), `toolCall.${callId}.arguments`) };
|
|
275
262
|
} catch (error) {
|
|
276
|
-
|
|
263
|
+
// Not a provider failure: the model gets the reason as a Tool error and writes the call again.
|
|
264
|
+
return { inputError: errorMessageOf(error) };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** HTTP statuses that mean "not now": the runtime repeats the request. */
|
|
269
|
+
function isUnavailableStatus(status: number): boolean {
|
|
270
|
+
return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const MAX_ERROR_BODY_BYTES = 64 * 1024;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Whether an error body says the request is longer than the model's window.
|
|
277
|
+
* The body is only classified: its text may repeat the prompt and never
|
|
278
|
+
* leaves this function.
|
|
279
|
+
*/
|
|
280
|
+
async function saysContextOverflow(response: Response, signal: AbortSignal): Promise<boolean> {
|
|
281
|
+
if (response.body === null) return false;
|
|
282
|
+
const reader = response.body.getReader();
|
|
283
|
+
const chunks: Uint8Array[] = [];
|
|
284
|
+
let total = 0;
|
|
285
|
+
const cancel = (): void => { void reader.cancel().catch(() => undefined); };
|
|
286
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
287
|
+
try {
|
|
288
|
+
while (total < MAX_ERROR_BODY_BYTES) {
|
|
289
|
+
const item = await reader.read();
|
|
290
|
+
if (item.done) break;
|
|
291
|
+
chunks.push(item.value);
|
|
292
|
+
total += item.value.byteLength;
|
|
293
|
+
}
|
|
294
|
+
const bytes = new Uint8Array(total);
|
|
295
|
+
let offset = 0;
|
|
296
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
297
|
+
const text = new TextDecoder().decode(bytes.subarray(0, MAX_ERROR_BODY_BYTES));
|
|
298
|
+
let code: unknown;
|
|
299
|
+
let message: unknown = text;
|
|
300
|
+
try {
|
|
301
|
+
const error = (JSON.parse(text) as { error?: { code?: unknown; message?: unknown } }).error;
|
|
302
|
+
code = error?.code;
|
|
303
|
+
if (typeof error?.message === "string") message = error.message;
|
|
304
|
+
} catch { /* Not JSON: the plain text is looked at. */ }
|
|
305
|
+
return code === "context_length_exceeded"
|
|
306
|
+
|| (typeof message === "string" && /context[ _-]?length|context window|maximum context|prompt is too long|too many (input )?tokens|input is too long/i.test(message));
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
} finally {
|
|
310
|
+
signal.removeEventListener("abort", cancel);
|
|
311
|
+
cancel();
|
|
277
312
|
}
|
|
278
313
|
}
|
|
279
314
|
|
|
315
|
+
/** `Retry-After`: seconds or an HTTP date. */
|
|
316
|
+
function retryAfterMsOf(header: string | null): number | undefined {
|
|
317
|
+
if (header === null) return undefined;
|
|
318
|
+
const text = header.trim();
|
|
319
|
+
if (/^\d+(\.\d+)?$/.test(text)) return Math.round(Number(text) * 1000);
|
|
320
|
+
const date = Date.parse(text);
|
|
321
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
322
|
+
}
|
|
323
|
+
|
|
280
324
|
function normalizeToolCalls(
|
|
281
325
|
calls: readonly OpenAiToolCall[] | undefined,
|
|
282
326
|
mapping: ToolNameMapping,
|
|
@@ -298,7 +342,7 @@ function normalizeToolCalls(
|
|
|
298
342
|
out.push(agentToolCall({
|
|
299
343
|
id: call.id,
|
|
300
344
|
name,
|
|
301
|
-
|
|
345
|
+
...parseToolArguments(call.function?.arguments ?? "", call.id),
|
|
302
346
|
}));
|
|
303
347
|
}
|
|
304
348
|
return Object.freeze(out);
|
|
@@ -312,6 +356,8 @@ function buildUsage(usage: OpenAiChatResponse["usage"]): AgentModelResponse["usa
|
|
|
312
356
|
...(usage.prompt_tokens !== undefined ? { inputTokens: usage.prompt_tokens } : {}),
|
|
313
357
|
...(usage.completion_tokens !== undefined ? { outputTokens: usage.completion_tokens } : {}),
|
|
314
358
|
...(usage.total_tokens !== undefined ? { totalTokens: usage.total_tokens } : {}),
|
|
359
|
+
...(typeof usage.prompt_tokens_details?.cached_tokens === "number" ? { cacheReadTokens: usage.prompt_tokens_details.cached_tokens } : {}),
|
|
360
|
+
...(typeof usage.completion_tokens_details?.reasoning_tokens === "number" ? { reasoningTokens: usage.completion_tokens_details.reasoning_tokens } : {}),
|
|
315
361
|
});
|
|
316
362
|
}
|
|
317
363
|
|
|
@@ -401,6 +447,11 @@ export class OpenAiCompatibleModelProvider implements AgentModelProvider {
|
|
|
401
447
|
}
|
|
402
448
|
}
|
|
403
449
|
|
|
450
|
+
/** The context window the application stated for this model (`contextTokens`). */
|
|
451
|
+
public contextTokens(): number | undefined {
|
|
452
|
+
return this.connection.contextTokens;
|
|
453
|
+
}
|
|
454
|
+
|
|
404
455
|
/** Base URL of the provider, for diagnostics. */
|
|
405
456
|
public get address(): string {
|
|
406
457
|
return this.baseUrl;
|
|
@@ -490,9 +541,10 @@ export class OpenAiCompatibleModelProvider implements AgentModelProvider {
|
|
|
490
541
|
continue;
|
|
491
542
|
}
|
|
492
543
|
|
|
544
|
+
const role = toOpenAiRole(message.role);
|
|
493
545
|
out.push(Object.freeze({
|
|
494
|
-
role
|
|
495
|
-
content: contentText(message.content),
|
|
546
|
+
role,
|
|
547
|
+
content: role === "user" ? userContent(message.content) : contentText(message.content),
|
|
496
548
|
}));
|
|
497
549
|
}
|
|
498
550
|
return Object.freeze(out);
|
|
@@ -507,19 +559,32 @@ export class OpenAiCompatibleModelProvider implements AgentModelProvider {
|
|
|
507
559
|
: setTimeout(() => abort.abort(new AgentModelTimeoutError("OpenAI-compatible request timed out.")), this.requestTimeoutMs);
|
|
508
560
|
try {
|
|
509
561
|
abort.signal.throwIfAborted();
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
562
|
+
let response: Response;
|
|
563
|
+
try {
|
|
564
|
+
response = await this.fetcher(`${this.baseUrl}${path}`, {
|
|
565
|
+
method,
|
|
566
|
+
headers: this.headers,
|
|
567
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
568
|
+
signal: abort.signal,
|
|
569
|
+
});
|
|
570
|
+
} catch (error) {
|
|
571
|
+
// A cancelled or timed-out request keeps its own reason; a broken connection can be repeated.
|
|
572
|
+
if (abort.signal.aborted) throw error;
|
|
573
|
+
throw new AgentModelUnavailableError(`OpenAI-compatible ${method} ${path} could not reach the provider: ${errorMessageOf(error)}`);
|
|
574
|
+
}
|
|
516
575
|
if (!response.ok) {
|
|
576
|
+
const message = `OpenAI-compatible ${method} ${path} failed with HTTP ${response.status}.`;
|
|
577
|
+
// A refusal of a chat request may mean "too long": the runtime answers that by compacting the dialog.
|
|
578
|
+
if (method === "POST" && (response.status === 400 || response.status === 413 || response.status === 422) && await saysContextOverflow(response, abort.signal)) {
|
|
579
|
+
throw new AgentModelContextOverflowError(`${message} The request does not fit the model's context window.`);
|
|
580
|
+
}
|
|
517
581
|
// Cancel the provider-controlled body without exposing it: error bodies
|
|
518
582
|
// may echo prompts, credentials or tenant data.
|
|
519
583
|
cancelBodyBestEffort(response.body);
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
584
|
+
if (isUnavailableStatus(response.status)) {
|
|
585
|
+
throw new AgentModelUnavailableError(message, { retryAfterMs: retryAfterMsOf(response.headers.get("retry-after")) });
|
|
586
|
+
}
|
|
587
|
+
throw new OpenAiCompatibleProviderError(message);
|
|
523
588
|
}
|
|
524
589
|
if (response.status === 204) {
|
|
525
590
|
return {};
|
|
@@ -121,10 +121,13 @@ export async function readOpenAiTextStream(
|
|
|
121
121
|
// EOF alone is not success: both a terminal choice and [DONE] are required.
|
|
122
122
|
if (!done) fail("was interrupted before completion");
|
|
123
123
|
const toolCalls = [...calls.entries()].sort(([a], [b]) => a - b).map(([index, call], position) => {
|
|
124
|
-
if (index !== position || !call.id || !call.function.name
|
|
124
|
+
if (index !== position || !call.id || !call.function.name) fail("contains an incomplete tool call");
|
|
125
125
|
return call;
|
|
126
126
|
});
|
|
127
|
-
|
|
127
|
+
// "length" with tool calls: the output limit cut the arguments. The calls are
|
|
128
|
+
// kept, so the model hears that the arguments are broken and writes them again.
|
|
129
|
+
const cut = finish === "length" && toolCalls.length > 0;
|
|
130
|
+
if ((!cut && (toolCalls.length > 0) !== (finish === "tool_calls")) || new Set(toolCalls.map(call => call.id)).size !== toolCalls.length) fail("contains inconsistent tool calls");
|
|
128
131
|
return { choices: [{ finish_reason: finish, message: { content: text.join(""), ...(toolCalls.length ? { tool_calls: toolCalls } : {}) } }], usage };
|
|
129
132
|
} finally {
|
|
130
133
|
signal.removeEventListener("abort", cancel);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { AgentToolContract } from "../../agent";
|
|
2
|
+
|
|
3
|
+
export interface ToolNameMapping {
|
|
4
|
+
readonly providerToBazis: ReadonlyMap<string, string>;
|
|
5
|
+
readonly bazisToProvider: ReadonlyMap<string, string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function shortHash(value: string): string {
|
|
9
|
+
let hash = 2166136261;
|
|
10
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
11
|
+
hash ^= value.charCodeAt(index);
|
|
12
|
+
hash = Math.imul(hash, 16777619);
|
|
13
|
+
}
|
|
14
|
+
return (hash >>> 0).toString(36);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A Tool name as providers accept it: Latin letters, digits, `_` and `-`, at most 64 characters, unique in a request. */
|
|
18
|
+
export function providerToolName(name: string, used: Set<string>): string {
|
|
19
|
+
const replaced = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
20
|
+
const prefixed = /^[a-zA-Z_]/.test(replaced) ? replaced : `tool_${replaced}`;
|
|
21
|
+
const trimmed = prefixed.length <= 64 ? prefixed : `${prefixed.slice(0, 55)}_${shortHash(name)}`;
|
|
22
|
+
if (!used.has(trimmed)) {
|
|
23
|
+
used.add(trimmed);
|
|
24
|
+
return trimmed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const suffix = shortHash(name);
|
|
28
|
+
const base = trimmed.slice(0, Math.max(1, 63 - suffix.length));
|
|
29
|
+
const unique = `${base}_${suffix}`;
|
|
30
|
+
if (used.has(unique)) {
|
|
31
|
+
throw new Error(`Tool name "${name}" collides with another Tool after its name is made acceptable to the model provider.`);
|
|
32
|
+
}
|
|
33
|
+
used.add(unique);
|
|
34
|
+
return unique;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Names of a request's Tools as the provider sees them, and the way back. */
|
|
38
|
+
export function toolNameMapping(tools: readonly AgentToolContract[]): ToolNameMapping {
|
|
39
|
+
const providerToBazis = new Map<string, string>();
|
|
40
|
+
const bazisToProvider = new Map<string, string>();
|
|
41
|
+
const used = new Set<string>();
|
|
42
|
+
for (let index = 0; index < tools.length; index += 1) {
|
|
43
|
+
const tool = tools[index] as AgentToolContract;
|
|
44
|
+
const providerName = providerToolName(tool.name, used);
|
|
45
|
+
providerToBazis.set(providerName, tool.name);
|
|
46
|
+
bazisToProvider.set(tool.name, providerName);
|
|
47
|
+
}
|
|
48
|
+
return Object.freeze({ providerToBazis, bazisToProvider });
|
|
49
|
+
}
|
|
@@ -191,6 +191,8 @@ interface ConstructorDependencies {
|
|
|
191
191
|
let classDeps = new Map<ts.ClassDeclaration, readonly CollectedDependency[]>();
|
|
192
192
|
/** `permissions` of the target's modules: the exported constant and its file. */
|
|
193
193
|
let permissionDeclarations = new Map<string, { readonly name: string; readonly file: string }>();
|
|
194
|
+
/** The prefixes of `permissionSources` of the target's modules: any key under one is a permission key. */
|
|
195
|
+
let permissionSourcePrefixes = new Set<string>();
|
|
194
196
|
let inferredDiClasses = new Set<ts.ClassDeclaration>();
|
|
195
197
|
let explicitlyBoundDiClasses = new Set<ts.ClassDeclaration>();
|
|
196
198
|
// Known runtime token names: createToken descriptions + class names usable as class-tokens.
|
|
@@ -507,7 +509,7 @@ function collectDynamicImportDiagnostics(source: ts.SourceFile, target: string,
|
|
|
507
509
|
|
|
508
510
|
function resetTargetAnalysis(files: Set<string>, generatedDir: string): void {
|
|
509
511
|
activeGeneratedDir = generatedDir;
|
|
510
|
-
classDeps = new Map(); inferredDiClasses = new Set(); explicitlyBoundDiClasses = new Set(); permissionDeclarations = new Map();
|
|
512
|
+
classDeps = new Map(); inferredDiClasses = new Set(); explicitlyBoundDiClasses = new Set(); permissionDeclarations = new Map(); permissionSourcePrefixes = new Set();
|
|
511
513
|
tokenDescriptions = new Set(); configPrefixesByType = new Map(); knownClassNames = new Set(); warnings = []; fatalErrors = [];
|
|
512
514
|
httpBindings = {}; httpControllerFiles = {}; openApiSchemas = {}; openApiOperations = {};
|
|
513
515
|
grpcBindings = new Map();
|
|
@@ -557,7 +559,7 @@ async function generateTarget(name: string, reachable: Set<string>, production:
|
|
|
557
559
|
stageWrite(`${generatedDir}/httpRequestModels.ts`, requestModels);
|
|
558
560
|
stageWrite(`${generatedDir}/httpListModels.ts`, listModels);
|
|
559
561
|
stageWrite(`${generatedDir}/openapi.ts`, openApi);
|
|
560
|
-
if (permissionDeclarations.size > 0) stageWrite(`${generatedDir}/permissions.ts`, renderPermissions());
|
|
562
|
+
if (permissionDeclarations.size > 0 || permissionSourcePrefixes.size > 0) stageWrite(`${generatedDir}/permissions.ts`, renderPermissions());
|
|
561
563
|
else stageRemove(`${generatedDir}/permissions.ts`);
|
|
562
564
|
// Generated before 0.99.0, when agents were classes; nothing imports it now.
|
|
563
565
|
stageRemove(`${generatedDir}/agentCatalog.ts`);
|
|
@@ -715,9 +717,17 @@ function renderGeneratedRuntime(targetId: string): string {
|
|
|
715
717
|
/** Types only: the keys of the target's permissions for `can`, `access.can`, `access.filter` and `defineRoles`. */
|
|
716
718
|
function renderPermissions(): string {
|
|
717
719
|
const entries = [...permissionDeclarations.values()].sort((left, right) => left.file.localeCompare(right.file) || left.name.localeCompare(right.name));
|
|
718
|
-
const lines = ["// This file is auto-generated by `bazis codegen`.", "// Do not edit manually."
|
|
720
|
+
const lines = ["// This file is auto-generated by `bazis codegen`.", "// Do not edit manually."];
|
|
721
|
+
// Without an import or an export the file would be a script, and `declare module` would replace the module instead of adding to it.
|
|
722
|
+
lines.push(entries.length > 0 ? 'import type { PermissionsOf } from "bazis/core/access";' : "export {};");
|
|
719
723
|
entries.forEach((entry, index) => lines.push(`import type { ${entry.name} as Permissions_${index} } from ${JSON.stringify(toModuleSpecifierFrom(activeGeneratedDir, entry.file))};`));
|
|
720
|
-
lines.push("", 'declare module "bazis/core/access" {'
|
|
724
|
+
lines.push("", 'declare module "bazis/core/access" {');
|
|
725
|
+
if (entries.length > 0) lines.push(` interface BazisPermissions extends ${entries.map((_, index) => `PermissionsOf<typeof Permissions_${index}>`).join(", ")} {}`);
|
|
726
|
+
// The prefixes of `permissionSources`: `${prefix}.${string}` is a permission key too (ACCESS-001 §3.1).
|
|
727
|
+
if (permissionSourcePrefixes.size > 0) {
|
|
728
|
+
lines.push(" interface BazisPermissionSources {", ...[...permissionSourcePrefixes].sort().map((prefix) => ` ${JSON.stringify(prefix)}: true;`), " }");
|
|
729
|
+
}
|
|
730
|
+
lines.push("}", "");
|
|
721
731
|
return lines.join("\n");
|
|
722
732
|
}
|
|
723
733
|
|
|
@@ -1036,6 +1046,9 @@ function collectDiClassRegistrations(source: ts.SourceFile): void {
|
|
|
1036
1046
|
// `references: { "orders.order": OrderReferences }`: the field registers the classes.
|
|
1037
1047
|
const references = objectFor(properties.get("references"));
|
|
1038
1048
|
if (references) for (const entry of propertiesOf(references).values()) record(entry);
|
|
1049
|
+
// `permissionSources: { data: DataPermissionSource }`: the field registers the classes too.
|
|
1050
|
+
const permissionSources = objectFor(properties.get("permissionSources"));
|
|
1051
|
+
if (permissionSources) for (const entry of propertiesOf(permissionSources).values()) record(entry);
|
|
1039
1052
|
// `permissions: orderPermissions`: the field registers the `lookup` classes its permissions name.
|
|
1040
1053
|
for (const lookup of permissionLookups(properties.get("permissions"))) record(lookup);
|
|
1041
1054
|
const imports = properties.get("imports");
|
|
@@ -1177,6 +1190,22 @@ function collectProviderFormErrors(source: ts.SourceFile): void {
|
|
|
1177
1190
|
}
|
|
1178
1191
|
permissionDeclarations.set(`${file}\0${exported.name}`, { name: exported.name, file });
|
|
1179
1192
|
};
|
|
1193
|
+
/**
|
|
1194
|
+
* `permissionSources: { data: DataPermissionSource }`: the prefixes go to the
|
|
1195
|
+
* generated `permissions.ts`, which makes every key under them a permission
|
|
1196
|
+
* key for `can` and `access.filter`. A framework pack names its own in its sources.
|
|
1197
|
+
*/
|
|
1198
|
+
const collectPermissionSources = (module: string, initializer: ts.Expression): void => {
|
|
1199
|
+
const file = normalizeProjectPath(initializer.getSourceFile().fileName);
|
|
1200
|
+
if (file.includes("/node_modules/") || isFrameworkPackPath(file)) return;
|
|
1201
|
+
const object = resolved(initializer, ts.isObjectLiteralExpression);
|
|
1202
|
+
const named = object?.properties.every((property) => ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)));
|
|
1203
|
+
if (!object || named !== true) {
|
|
1204
|
+
fatalErrors.push(`BAZIS_ACCESS_SOURCES_FORM: ${sourceLocation(initializer)}: permissionSources of ${module}: "${text(initializer)}" — write an object "prefix: Class" in the module, so that the keys under the prefixes become the type of can(...).`);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
for (const property of object.properties) permissionSourcePrefixes.add((property.name as ts.Identifier | ts.StringLiteralLike).text);
|
|
1208
|
+
};
|
|
1180
1209
|
const checkModule = (module: string, argument: ts.Expression | undefined): void => {
|
|
1181
1210
|
const object = argument && resolved(argument, ts.isObjectLiteralExpression);
|
|
1182
1211
|
if (!object) return;
|
|
@@ -1184,6 +1213,7 @@ function collectProviderFormErrors(source: ts.SourceFile): void {
|
|
|
1184
1213
|
if (!ts.isPropertyAssignment(property)) continue;
|
|
1185
1214
|
const field = property.name.getText().replace(/["']/g, "");
|
|
1186
1215
|
if (field === "permissions") collectPermissions(module, property.initializer);
|
|
1216
|
+
if (field === "permissionSources") collectPermissionSources(module, property.initializer);
|
|
1187
1217
|
if (field !== "providers" && !CLASS_LISTS.includes(field)) continue;
|
|
1188
1218
|
const list = resolved(property.initializer, ts.isArrayLiteralExpression);
|
|
1189
1219
|
if (!list) {
|
package/core/websocket/DESIGN.md
CHANGED
|
@@ -22,7 +22,7 @@ export class ChatGateway {
|
|
|
22
22
|
}
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Declare it in its module, like a controller; `runApp` serves it on the HTTP listener:
|
|
25
|
+
Declare it in its module, like a controller; the application started by `runApp(Application)` serves it on the HTTP listener:
|
|
26
26
|
|
|
27
27
|
```ts
|
|
28
28
|
import { Module, singleton } from "@/core/di";
|
|
@@ -34,7 +34,7 @@ export class ChatModule {}
|
|
|
34
34
|
export class Application {}
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
Without
|
|
37
|
+
Without `@App`, `websocketModule({ imports: [ChatModule], ... })` next to
|
|
38
38
|
`httpModule` does the same. Services send with `WebSocketService`:
|
|
39
39
|
`this.ws.to("user:ann").emit("order-paid", { id })`.
|
|
40
40
|
|
package/index.ts
CHANGED
|
@@ -84,6 +84,8 @@ export {
|
|
|
84
84
|
AgentRuntimeError,
|
|
85
85
|
AgentSemanticError,
|
|
86
86
|
AgentModelTimeoutError,
|
|
87
|
+
AgentModelUnavailableError,
|
|
88
|
+
AgentModelContextOverflowError,
|
|
87
89
|
AgentToolExecutionError,
|
|
88
90
|
ToolError,
|
|
89
91
|
ApproximateAgentContextTokenEstimator,
|
|
@@ -94,6 +96,7 @@ export {
|
|
|
94
96
|
agentInvocation,
|
|
95
97
|
agentJsonSchema,
|
|
96
98
|
agentMessage,
|
|
99
|
+
agentMessageText,
|
|
97
100
|
agentModelCapabilities,
|
|
98
101
|
agentModelRequest,
|
|
99
102
|
agentModelResponse,
|
|
@@ -117,6 +120,8 @@ export {
|
|
|
117
120
|
type AgentOutputContract,
|
|
118
121
|
type AgentRuntimeInvokeOptions,
|
|
119
122
|
type AgentRuntimeOptions,
|
|
123
|
+
type AgentModelRetryPolicy,
|
|
124
|
+
type AgentCompactionOptions,
|
|
120
125
|
type AgentRuntimeResult,
|
|
121
126
|
type AgentRuntimeStatus,
|
|
122
127
|
type AgentRuntimeToolExecutionOptions,
|
|
@@ -284,14 +284,16 @@ class MyModule {}
|
|
|
284
284
|
|
|
285
285
|
### Explicit dependencies in a provider
|
|
286
286
|
|
|
287
|
+
An application never lists dependencies: codegen reads the constructor
|
|
288
|
+
(`scoped(UserService)` is enough). A package that extends the framework and builds
|
|
289
|
+
providers by hand takes the low-level forms from `bazis/core/di/lowLevel` (0.138.0):
|
|
290
|
+
|
|
287
291
|
```ts
|
|
288
|
-
import {
|
|
289
|
-
import { repositoryFor } from "
|
|
292
|
+
import { DI } from "bazis/core/di/lowLevel";
|
|
293
|
+
import { repositoryFor } from "bazis/core/orm";
|
|
290
294
|
|
|
291
295
|
providers: [
|
|
292
|
-
scoped(
|
|
293
|
-
DI.classProvider(UserService, UserService, [repositoryFor(User)] as const),
|
|
294
|
-
),
|
|
296
|
+
DI.scoped(DI.classProvider(UserService, UserService, [repositoryFor(User)] as const)),
|
|
295
297
|
],
|
|
296
298
|
```
|
|
297
299
|
|
|
@@ -385,12 +387,11 @@ export class UserService {
|
|
|
385
387
|
|
|
386
388
|
```ts
|
|
387
389
|
import { Module, scoped } from "@/core/di";
|
|
388
|
-
import { repositoryFor } from "@/core/orm";
|
|
389
390
|
|
|
390
391
|
@Module({
|
|
391
392
|
imports: [DataModule],
|
|
392
393
|
providers: [
|
|
393
|
-
scoped(UserService,
|
|
394
|
+
scoped(UserService), // codegen reads `IRepository<User>` from the constructor
|
|
394
395
|
],
|
|
395
396
|
exports: [UserService],
|
|
396
397
|
})
|
|
@@ -517,7 +518,9 @@ books.add(book);
|
|
|
517
518
|
`di:generate` wires a constructor parameter typed `IRepository<User>` automatically. Explicit deps are needed only when the class is registered without codegen (for example in a hand-built test container):
|
|
518
519
|
|
|
519
520
|
```ts
|
|
520
|
-
|
|
521
|
+
import { DI } from "bazis/core/di/lowLevel"; // since 0.138.0; not in bazis/core/di
|
|
522
|
+
|
|
523
|
+
DI.scoped(DI.classProvider(UserService, UserService, [repositoryFor(User)] as const))
|
|
521
524
|
```
|
|
522
525
|
|
|
523
526
|
### 4. `ensureCreated` in tests without starting the application
|
|
@@ -596,7 +599,7 @@ A mock usually needs only the methods the service calls.
|
|
|
596
599
|
For **controller** tests it is enough to replace the store contract the controller depends on (here a hypothetical `IUserStore`):
|
|
597
600
|
|
|
598
601
|
```ts
|
|
599
|
-
import { Module, createContainer,
|
|
602
|
+
import { Module, createContainer, singletonValue } from "bazis/core/di";
|
|
600
603
|
import { InMemoryUserStore } from "./InMemoryUserStore";
|
|
601
604
|
import { IUserStore } from "./IUserStore";
|
|
602
605
|
import { UsersController } from "./UsersController";
|
|
@@ -604,7 +607,7 @@ import { UsersController } from "./UsersController";
|
|
|
604
607
|
@Module({
|
|
605
608
|
controllers: [UsersController],
|
|
606
609
|
providers: [
|
|
607
|
-
|
|
610
|
+
singletonValue(IUserStore, new InMemoryUserStore()),
|
|
608
611
|
],
|
|
609
612
|
})
|
|
610
613
|
class TestUsersModule {}
|
|
@@ -652,7 +655,7 @@ expect(await service.list(10)).toHaveLength(1);
|
|
|
652
655
|
1. **Do not mock `query()`** if you test SQL/filters: use PostgreSQL qualification.
|
|
653
656
|
2. **Mock `saveChanges()`** to check that the service saves data at all.
|
|
654
657
|
3. **One scope per test**: `scope.dispose()` in `afterEach`, otherwise scoped services leak.
|
|
655
|
-
4. In hand-built DI test containers (without codegen) with `validateOnBuild: true`, give Repository deps explicitly
|
|
658
|
+
4. In hand-built DI test containers (without codegen) with `validateOnBuild: true`, give Repository deps explicitly through `DI.classProvider(…, [repositoryFor(User)])` from `bazis/core/di/lowLevel`. With codegen, prefer `createTestContainer(Application, { overrides })` from `bazis/core/testing`.
|
|
656
659
|
|
|
657
660
|
---
|
|
658
661
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.140.0",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"./core/http-client": "./core/http-client/index.ts",
|
|
43
43
|
"./core/http/OpenApi/generatedOpenApiRegistry": "./core/http/OpenApi/generatedOpenApiRegistry.ts",
|
|
44
44
|
"./core/infra": "./core/infra/index.ts",
|
|
45
|
+
"./core/infra/anthropic": "./core/infra/anthropic.ts",
|
|
45
46
|
"./core/kernel": "./core/kernel/index.ts",
|
|
46
47
|
"./core/orm": "./core/orm/index.ts",
|
|
47
48
|
"./core/testing": "./core/testing/index.ts",
|
|
@@ -81,7 +82,8 @@
|
|
|
81
82
|
},
|
|
82
83
|
"peerDependencies": {
|
|
83
84
|
"typescript": "^5.9.3 || ^6.0.0 || ^7.0.0",
|
|
84
|
-
"@typescript/typescript6": "^6.0.0"
|
|
85
|
+
"@typescript/typescript6": "^6.0.0",
|
|
86
|
+
"@anthropic-ai/sdk": "^1.8.0"
|
|
85
87
|
},
|
|
86
88
|
"publishConfig": {
|
|
87
89
|
"access": "public",
|
|
@@ -90,6 +92,9 @@
|
|
|
90
92
|
"peerDependenciesMeta": {
|
|
91
93
|
"@typescript/typescript6": {
|
|
92
94
|
"optional": true
|
|
95
|
+
},
|
|
96
|
+
"@anthropic-ai/sdk": {
|
|
97
|
+
"optional": true
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
}
|