bazis 0.138.0 → 0.139.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/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/websocket/DESIGN.md +2 -2
- package/index.ts +5 -0
- package/library/orm/Repository/README.md +14 -11
- package/package.json +7 -2
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { jsonSchemaOutputFormat } from "@anthropic-ai/sdk/helpers/json-schema";
|
|
3
|
+
import {
|
|
4
|
+
AgentModelContextOverflowError,
|
|
5
|
+
AgentModelTimeoutError,
|
|
6
|
+
AgentModelUnavailableError,
|
|
7
|
+
agentMessage,
|
|
8
|
+
agentModelResponse,
|
|
9
|
+
agentReasoning,
|
|
10
|
+
agentText,
|
|
11
|
+
agentToolCall,
|
|
12
|
+
type AgentContentPart,
|
|
13
|
+
type AgentFinishReason,
|
|
14
|
+
type AgentMessage,
|
|
15
|
+
type AgentModelProvider,
|
|
16
|
+
type AgentModelProviderContext,
|
|
17
|
+
type AgentModelRequest,
|
|
18
|
+
type AgentModelResponse,
|
|
19
|
+
type AgentToolCall,
|
|
20
|
+
type AgentToolContract,
|
|
21
|
+
type JsonObject,
|
|
22
|
+
} from "../../agent";
|
|
23
|
+
import { redactSensitiveText } from "../../../library/redaction";
|
|
24
|
+
import { InfraError } from "../InfraConnector";
|
|
25
|
+
import { DEFAULT_LLM_REQUEST_TIMEOUT_MS, type LlmConnectionOptions, type LlmProviderAdapter } from "./llm";
|
|
26
|
+
import { providerToolName, toolNameMapping, type ToolNameMapping } from "./toolNames";
|
|
27
|
+
|
|
28
|
+
const DEFAULT_MAX_OUTPUT_TOKENS = 32_000;
|
|
29
|
+
/** The start check is short: a provider that does not answer in five seconds is not ready. */
|
|
30
|
+
const START_CHECK_TIMEOUT_MS = 5_000;
|
|
31
|
+
/** `reasoning.data.provider` of the blocks this adapter wrote and may send back. */
|
|
32
|
+
const PROVIDER = "anthropic";
|
|
33
|
+
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
|
34
|
+
|
|
35
|
+
/** The part of the SDK client the adapter uses: the Claude API client, a cloud platform's client, or a test double. */
|
|
36
|
+
export type AnthropicClient = Pick<Anthropic, "messages" | "models">;
|
|
37
|
+
|
|
38
|
+
export interface AnthropicAdapterOptions {
|
|
39
|
+
/** `max_tokens` of a request that sets no `maxOutputTokens`. 32 000 by default. */
|
|
40
|
+
readonly maxOutputTokens?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Adaptive thinking: the model decides when and how much to think. On by
|
|
43
|
+
* default; `false` sends no `thinking` setting, for a model that takes none.
|
|
44
|
+
*/
|
|
45
|
+
readonly thinking?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* `"summarized"` returns a readable summary of the reasoning as the text of
|
|
48
|
+
* `reasoning` parts. By default the model's own setting applies, and on
|
|
49
|
+
* current models the text is empty.
|
|
50
|
+
*/
|
|
51
|
+
readonly thinkingDisplay?: "summarized" | "omitted";
|
|
52
|
+
/** How thoroughly the model works (`output_config.effort`). The model's own default when not set. */
|
|
53
|
+
readonly effort?: "low" | "medium" | "high" | "xhigh" | "max";
|
|
54
|
+
/**
|
|
55
|
+
* Prompt caching: the instructions, the Tools and the dialog so far are read
|
|
56
|
+
* from the provider's cache on the next step at a fraction of the price.
|
|
57
|
+
* `true` keeps an entry five minutes (the default), `"1h"` an hour, `false`
|
|
58
|
+
* turns caching off.
|
|
59
|
+
*/
|
|
60
|
+
readonly cache?: boolean | "1h";
|
|
61
|
+
/** Check the provider in `/health`. `true` by default. */
|
|
62
|
+
readonly healthCheck?: boolean;
|
|
63
|
+
/** A ready SDK client instead of one built from the configuration. */
|
|
64
|
+
readonly client?: AnthropicClient;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class AnthropicProviderError extends Error {
|
|
68
|
+
public constructor(message: string) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = "AnthropicProviderError";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Cache = { readonly type: "ephemeral"; readonly ttl?: "1h" };
|
|
75
|
+
|
|
76
|
+
function errorMessageOf(error: unknown): string {
|
|
77
|
+
try {
|
|
78
|
+
return error instanceof Error && typeof error.message === "string" ? error.message : String(error);
|
|
79
|
+
} catch {
|
|
80
|
+
return "Unknown error.";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `data:<type>;base64,<bytes>` taken apart; `undefined` for an address. */
|
|
85
|
+
function inlineData(uri: string): { readonly mediaType: string; readonly data: string } | undefined {
|
|
86
|
+
const match = /^data:([^;,]+);base64,(.*)$/s.exec(uri);
|
|
87
|
+
return match === null ? undefined : { mediaType: match[1]!.toLowerCase(), data: match[2]! };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resultText(part: Extract<AgentContentPart, { readonly kind: "tool-result" }>): string {
|
|
91
|
+
// Only the model-facing result: runtime metadata and timings stay in bazis.
|
|
92
|
+
return JSON.stringify(part.result.status === "success" ? part.result.output ?? null : part.result.error ?? { message: "Tool failed." });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One user-side content part as the Messages API takes it; `undefined` for a part that says nothing. */
|
|
96
|
+
function userBlock(part: AgentContentPart): Anthropic.ContentBlockParam | undefined {
|
|
97
|
+
switch (part.kind) {
|
|
98
|
+
case "text":
|
|
99
|
+
return part.text.length === 0 ? undefined : { type: "text", text: part.text };
|
|
100
|
+
case "data":
|
|
101
|
+
return { type: "text", text: JSON.stringify({ name: part.name ?? null, value: part.value }) };
|
|
102
|
+
case "image": {
|
|
103
|
+
const inline = inlineData(part.uri);
|
|
104
|
+
if (inline === undefined) return { type: "image", source: { type: "url", url: part.uri } };
|
|
105
|
+
if (!IMAGE_TYPES.has(inline.mediaType)) throw new AnthropicProviderError(`Image type "${inline.mediaType}" is not accepted: send JPEG, PNG, GIF or WebP.`);
|
|
106
|
+
return { type: "image", source: { type: "base64", media_type: inline.mediaType as Anthropic.Base64ImageSource["media_type"], data: inline.data } };
|
|
107
|
+
}
|
|
108
|
+
case "file": {
|
|
109
|
+
const inline = inlineData(part.uri);
|
|
110
|
+
const title = part.name === undefined ? {} : { title: part.name };
|
|
111
|
+
if (inline === undefined) return { type: "document", source: { type: "url", url: part.uri }, ...title };
|
|
112
|
+
if (inline.mediaType === "application/pdf") return { type: "document", source: { type: "base64", media_type: "application/pdf", data: inline.data }, ...title };
|
|
113
|
+
if (inline.mediaType.startsWith("text/")) return { type: "document", source: { type: "text", media_type: "text/plain", data: Buffer.from(inline.data, "base64").toString("utf8") }, ...title };
|
|
114
|
+
throw new AnthropicProviderError(`File type "${inline.mediaType}" is not accepted: send a PDF or a text file.`);
|
|
115
|
+
}
|
|
116
|
+
case "tool-result":
|
|
117
|
+
return { type: "tool_result", tool_use_id: part.result.callId, content: resultText(part), ...(part.result.status === "success" ? {} : { is_error: true }) };
|
|
118
|
+
default:
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One assistant content part; reasoning goes back only as this provider wrote it. */
|
|
124
|
+
function assistantBlock(part: AgentContentPart, mapping: ToolNameMapping, withReasoning: boolean): Anthropic.ContentBlockParam | undefined {
|
|
125
|
+
switch (part.kind) {
|
|
126
|
+
case "text":
|
|
127
|
+
return part.text.length === 0 ? undefined : { type: "text", text: part.text };
|
|
128
|
+
case "data":
|
|
129
|
+
return { type: "text", text: JSON.stringify({ name: part.name ?? null, value: part.value }) };
|
|
130
|
+
case "reasoning": {
|
|
131
|
+
const data = part.data;
|
|
132
|
+
if (!withReasoning || data === undefined || data.provider !== PROVIDER) return undefined;
|
|
133
|
+
if (data.type === "thinking" && typeof data.signature === "string") return { type: "thinking", thinking: part.text, signature: data.signature };
|
|
134
|
+
if (data.type === "redacted_thinking" && typeof data.data === "string") return { type: "redacted_thinking", data: data.data };
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
case "tool-call": {
|
|
138
|
+
// A call to a Tool outside this request keeps a name no request Tool uses, next to its denial.
|
|
139
|
+
const name = mapping.bazisToProvider.get(part.call.name) ?? providerToolName(part.call.name, new Set(mapping.providerToBazis.keys()));
|
|
140
|
+
const input = part.call.input;
|
|
141
|
+
return { type: "tool_use", id: part.call.id, name, input: typeof input === "object" && input !== null && !Array.isArray(input) ? input : {} };
|
|
142
|
+
}
|
|
143
|
+
default:
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function finishReasonOf(reason: Anthropic.Message["stop_reason"], hasToolCalls: boolean): AgentFinishReason {
|
|
149
|
+
if (reason === "refusal") return "content-filter";
|
|
150
|
+
if (hasToolCalls) return "tool-calls";
|
|
151
|
+
if (reason === "end_turn" || reason === "stop_sequence" || reason === null) return "stop";
|
|
152
|
+
if (reason === "max_tokens") return "length";
|
|
153
|
+
return "error";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export class AnthropicModelProvider implements AgentModelProvider {
|
|
157
|
+
private readonly client: AnthropicClient;
|
|
158
|
+
private readonly maxOutputTokens: number;
|
|
159
|
+
private readonly cache: Cache | undefined;
|
|
160
|
+
/** The window the Models API reported at the start check. */
|
|
161
|
+
private reportedWindow: number | undefined;
|
|
162
|
+
/** Runs whose stored reasoning the provider refused once: it is not sent again. */
|
|
163
|
+
private readonly withoutReasoning = new Set<string>();
|
|
164
|
+
|
|
165
|
+
public constructor(
|
|
166
|
+
private readonly connection: LlmConnectionOptions,
|
|
167
|
+
private readonly options: AnthropicAdapterOptions = {},
|
|
168
|
+
) {
|
|
169
|
+
if (options.maxOutputTokens !== undefined && (!Number.isSafeInteger(options.maxOutputTokens) || options.maxOutputTokens <= 0)) {
|
|
170
|
+
throw new AnthropicProviderError("maxOutputTokens must be a positive safe integer.");
|
|
171
|
+
}
|
|
172
|
+
this.maxOutputTokens = options.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
|
173
|
+
this.cache = options.cache === false ? undefined : options.cache === "1h" ? { type: "ephemeral", ttl: "1h" } : { type: "ephemeral" };
|
|
174
|
+
const configured = connection.timeoutMs ?? DEFAULT_LLM_REQUEST_TIMEOUT_MS;
|
|
175
|
+
this.client = options.client ?? new Anthropic({
|
|
176
|
+
apiKey: connection.apiKey,
|
|
177
|
+
...(connection.baseUrl.trim().length === 0 ? {} : { baseURL: connection.baseUrl }),
|
|
178
|
+
// `0` means no deadline of the connector; the runtime's own deadlines still apply.
|
|
179
|
+
timeout: configured === 0 ? 60 * 60 * 1000 : configured,
|
|
180
|
+
// The agent runtime repeats a request itself (`modelRetry`); two layers would multiply the attempts.
|
|
181
|
+
maxRetries: 0,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
public async complete(request: AgentModelRequest, context: AgentModelProviderContext): Promise<AgentModelResponse> {
|
|
186
|
+
if (request.model !== undefined && request.model !== this.connection.model) {
|
|
187
|
+
throw new AnthropicProviderError("The request model conflicts with the configured Infra model.");
|
|
188
|
+
}
|
|
189
|
+
const mapping = toolNameMapping(request.tools);
|
|
190
|
+
const onText = !request.output || request.output.mode === "text" ? context.onTextDelta : undefined;
|
|
191
|
+
let message: Anthropic.Message;
|
|
192
|
+
try {
|
|
193
|
+
message = await this.send(this.params(request, mapping, !this.withoutReasoning.has(request.invocationId)), context.signal, onText);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (!this.refusesStoredReasoning(error) || this.withoutReasoning.has(request.invocationId)) throw this.failure(error);
|
|
196
|
+
// The dialog changed since the model reasoned about it (instructions, Tools or
|
|
197
|
+
// history were edited). The request is decided before any output, so it is
|
|
198
|
+
// sent once more without the reasoning; the words and Tool calls stay.
|
|
199
|
+
this.rememberWithoutReasoning(request.invocationId);
|
|
200
|
+
try {
|
|
201
|
+
message = await this.send(this.params(request, mapping, false), context.signal, onText);
|
|
202
|
+
} catch (second) {
|
|
203
|
+
throw this.failure(second);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return this.response(request, message, mapping);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The model's context window: stated by the application, or reported by the provider at the start check. */
|
|
210
|
+
public contextTokens(): number | undefined {
|
|
211
|
+
return this.connection.contextTokens ?? this.reportedWindow;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Base URL of the provider, for diagnostics. */
|
|
215
|
+
public get address(): string {
|
|
216
|
+
return this.connection.baseUrl;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Asks the provider about the configured model: a wrong key, address or model id rejects. */
|
|
220
|
+
public async probe(signal?: AbortSignal): Promise<void> {
|
|
221
|
+
try {
|
|
222
|
+
const model = await this.client.models.retrieve(this.connection.model, undefined, { signal });
|
|
223
|
+
if (typeof model.max_input_tokens === "number" && model.max_input_tokens > 0) this.reportedWindow = model.max_input_tokens;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw this.failure(error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
public async ping(signal?: AbortSignal): Promise<boolean> {
|
|
230
|
+
try {
|
|
231
|
+
await this.probe(signal);
|
|
232
|
+
return true;
|
|
233
|
+
} catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async send(params: Anthropic.MessageStreamParams, signal: AbortSignal, onText: ((text: string) => void) | undefined): Promise<Anthropic.Message> {
|
|
239
|
+
// Always a stream: a long answer does not run into an HTTP timeout, and the
|
|
240
|
+
// SDK assembles the complete message. Tool input is left buffered (no
|
|
241
|
+
// eager input streaming): nothing here shows it as it is written, and the
|
|
242
|
+
// API then delivers only complete, valid JSON.
|
|
243
|
+
const stream = this.client.messages.stream(params, { signal });
|
|
244
|
+
if (onText !== undefined) stream.on("text", (delta) => onText(delta));
|
|
245
|
+
return stream.finalMessage();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private params(request: AgentModelRequest, mapping: ToolNameMapping, withReasoning: boolean): Anthropic.MessageStreamParams {
|
|
249
|
+
const { system, messages } = this.dialog(request.messages, mapping, withReasoning);
|
|
250
|
+
const format = request.output?.mode === "json" ? this.outputFormat(request) : undefined;
|
|
251
|
+
const outputConfig = {
|
|
252
|
+
...(this.options.effort === undefined ? {} : { effort: this.options.effort }),
|
|
253
|
+
...(format === undefined ? {} : { format }),
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
model: this.connection.model,
|
|
257
|
+
max_tokens: request.maxOutputTokens ?? this.maxOutputTokens,
|
|
258
|
+
messages,
|
|
259
|
+
...(system.length === 0 ? {} : { system }),
|
|
260
|
+
...(request.tools.length === 0 ? {} : { tools: request.tools.map((tool) => this.tool(tool, mapping)) }),
|
|
261
|
+
...(this.options.thinking === false ? {} : { thinking: { type: "adaptive" as const, ...(this.options.thinkingDisplay === undefined ? {} : { display: this.options.thinkingDisplay }) } }),
|
|
262
|
+
...(Object.keys(outputConfig).length === 0 ? {} : { output_config: outputConfig }),
|
|
263
|
+
// The dialog so far is cached as it grows; the instructions keep their own read point above.
|
|
264
|
+
...(this.cache === undefined ? {} : { cache_control: this.cache }),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private outputFormat(request: AgentModelRequest): Anthropic.JSONOutputFormat | undefined {
|
|
269
|
+
const schema = request.output?.schema;
|
|
270
|
+
if (schema === undefined) return undefined;
|
|
271
|
+
if (schema.kind === "class") {
|
|
272
|
+
throw new AnthropicProviderError(`Output schema "${schema.name}" needs generated JSON Schema metadata for this provider.`);
|
|
273
|
+
}
|
|
274
|
+
// The SDK drops the rules structured outputs do not take; the runtime still checks the answer against the whole schema.
|
|
275
|
+
const format = jsonSchemaOutputFormat(schema.schema as never);
|
|
276
|
+
return { type: "json_schema", schema: format.schema };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private tool(tool: AgentToolContract, mapping: ToolNameMapping): Anthropic.Tool {
|
|
280
|
+
const schema: JsonObject = tool.input?.kind === "json-schema" ? tool.input.schema : { type: "object", additionalProperties: true };
|
|
281
|
+
return {
|
|
282
|
+
name: mapping.bazisToProvider.get(tool.name) ?? tool.name,
|
|
283
|
+
description: tool.description,
|
|
284
|
+
input_schema: { ...schema, type: "object" },
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The dialog as the Messages API takes it: instructions on top, then turns of the user and the assistant. */
|
|
289
|
+
private dialog(source: readonly AgentMessage[], mapping: ToolNameMapping, withReasoning: boolean): { readonly system: Anthropic.TextBlockParam[]; readonly messages: Anthropic.MessageParam[] } {
|
|
290
|
+
const system: Anthropic.TextBlockParam[] = [];
|
|
291
|
+
const messages: Anthropic.MessageParam[] = [];
|
|
292
|
+
let leading = true;
|
|
293
|
+
const append = (role: "user" | "assistant", blocks: readonly Anthropic.ContentBlockParam[]): void => {
|
|
294
|
+
if (blocks.length === 0) return;
|
|
295
|
+
const last = messages[messages.length - 1];
|
|
296
|
+
// Results of one step's Tools, and whatever the user says after them, are one user turn.
|
|
297
|
+
if (last !== undefined && last.role === role && Array.isArray(last.content)) last.content.push(...blocks);
|
|
298
|
+
else messages.push({ role, content: [...blocks] });
|
|
299
|
+
};
|
|
300
|
+
for (const message of source) {
|
|
301
|
+
if ((message.role === "system" || message.role === "developer") && leading) {
|
|
302
|
+
for (const part of message.content) if (part.kind === "text" && part.text.length > 0) system.push({ type: "text", text: part.text });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
leading = false;
|
|
306
|
+
if (message.role === "assistant") {
|
|
307
|
+
append("assistant", message.content.map((part) => assistantBlock(part, mapping, withReasoning)).filter((block) => block !== undefined));
|
|
308
|
+
} else {
|
|
309
|
+
// A user, a Tool result, or instructions that arrive in the middle of a dialog.
|
|
310
|
+
append("user", message.content.map(userBlock).filter((block) => block !== undefined));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (messages[0]?.role === "assistant") messages.unshift({ role: "user", content: [{ type: "text", text: "(The conversation continues.)" }] });
|
|
314
|
+
const lastSystem = system[system.length - 1];
|
|
315
|
+
if (lastSystem !== undefined && this.cache !== undefined) lastSystem.cache_control = this.cache;
|
|
316
|
+
return { system, messages };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private response(request: AgentModelRequest, message: Anthropic.Message, mapping: ToolNameMapping): AgentModelResponse {
|
|
320
|
+
if (message.stop_reason === "model_context_window_exceeded") {
|
|
321
|
+
throw new AgentModelContextOverflowError("The answer did not fit the model's context window.");
|
|
322
|
+
}
|
|
323
|
+
const content: AgentContentPart[] = [];
|
|
324
|
+
const toolCalls: AgentToolCall[] = [];
|
|
325
|
+
for (const block of message.content) {
|
|
326
|
+
if (block.type === "text") {
|
|
327
|
+
if (block.text.length > 0) content.push(agentText(block.text));
|
|
328
|
+
} else if (block.type === "thinking") {
|
|
329
|
+
content.push(agentReasoning(block.thinking, { data: { provider: PROVIDER, type: "thinking", signature: block.signature } }));
|
|
330
|
+
} else if (block.type === "redacted_thinking") {
|
|
331
|
+
content.push(agentReasoning("", { data: { provider: PROVIDER, type: "redacted_thinking", data: block.data } }));
|
|
332
|
+
} else if (block.type === "tool_use") {
|
|
333
|
+
toolCalls.push(agentToolCall({ id: block.id, name: mapping.providerToBazis.get(block.name) ?? block.name, input: block.input }));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// A refusal can cut a Tool call off: none of that turn's Tools is run.
|
|
337
|
+
const calls = message.stop_reason === "refusal" ? [] : toolCalls;
|
|
338
|
+
if (message.stop_reason === "max_tokens" && calls.length > 0) {
|
|
339
|
+
// The output limit ended the turn inside its last call: the arguments may be incomplete.
|
|
340
|
+
const last = calls[calls.length - 1]!;
|
|
341
|
+
calls[calls.length - 1] = agentToolCall({ id: last.id, name: last.name, inputError: "The output limit cut the arguments off; write the call again, shorter if needed." });
|
|
342
|
+
}
|
|
343
|
+
if (content.length === 0 && calls.length === 0) {
|
|
344
|
+
if (message.stop_reason === "refusal") content.push(agentText("The model declined to answer."));
|
|
345
|
+
else throw new AnthropicProviderError(`The model returned no content (stop_reason "${message.stop_reason ?? "none"}").`);
|
|
346
|
+
}
|
|
347
|
+
const usage = message.usage;
|
|
348
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
349
|
+
const cacheWrite = usage.cache_creation_input_tokens ?? 0;
|
|
350
|
+
// `input_tokens` counts only what was not cached; the request is all three.
|
|
351
|
+
const inputTokens = usage.input_tokens + cacheRead + cacheWrite;
|
|
352
|
+
return agentModelResponse({
|
|
353
|
+
invocationId: request.invocationId,
|
|
354
|
+
finishReason: finishReasonOf(message.stop_reason, calls.length > 0),
|
|
355
|
+
message: content.length === 0 ? undefined : agentMessage("assistant", content),
|
|
356
|
+
toolCalls: calls,
|
|
357
|
+
usage: {
|
|
358
|
+
inputTokens,
|
|
359
|
+
outputTokens: usage.output_tokens,
|
|
360
|
+
totalTokens: inputTokens + usage.output_tokens,
|
|
361
|
+
...(cacheRead > 0 ? { cacheReadTokens: cacheRead } : {}),
|
|
362
|
+
...(cacheWrite > 0 ? { cacheWriteTokens: cacheWrite } : {}),
|
|
363
|
+
...(typeof usage.output_tokens_details?.thinking_tokens === "number" ? { reasoningTokens: usage.output_tokens_details.thinking_tokens } : {}),
|
|
364
|
+
},
|
|
365
|
+
metadata: {
|
|
366
|
+
model: message.model,
|
|
367
|
+
...(message.stop_reason === "refusal" && message.stop_details?.category != null ? { refusal: message.stop_details.category } : {}),
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whether the API refused reasoning replayed from a dialog that has changed
|
|
374
|
+
* since. The API has no error type for it: the text of the 400 is the only sign.
|
|
375
|
+
*/
|
|
376
|
+
private refusesStoredReasoning(error: unknown): boolean {
|
|
377
|
+
return error instanceof Anthropic.BadRequestError && /signature.*thinking|thinking.*signature/is.test(error.message);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private rememberWithoutReasoning(invocationId: string): void {
|
|
381
|
+
if (this.withoutReasoning.size >= 1_000) this.withoutReasoning.delete(this.withoutReasoning.values().next().value as string);
|
|
382
|
+
this.withoutReasoning.add(invocationId);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The SDK's error as the agent runtime understands it. The API's own message
|
|
387
|
+
* is not repeated: it may quote the request. The status, the error type and
|
|
388
|
+
* the request id are enough to find the request in the provider's console.
|
|
389
|
+
*/
|
|
390
|
+
private failure(error: unknown): unknown {
|
|
391
|
+
// A cancelled request keeps its own reason.
|
|
392
|
+
if (error instanceof Anthropic.APIUserAbortError) return error;
|
|
393
|
+
if (error instanceof Anthropic.APIConnectionTimeoutError) return new AgentModelTimeoutError("Anthropic request timed out.");
|
|
394
|
+
if (error instanceof Anthropic.APIConnectionError) return new AgentModelUnavailableError("Anthropic could not be reached.");
|
|
395
|
+
if (!(error instanceof Anthropic.APIError)) return error;
|
|
396
|
+
const described = `Anthropic request failed with HTTP ${error.status ?? "?"}${error.type == null ? "" : ` (${error.type})`}${error.requestID == null ? "" : `, request ${error.requestID}`}.`;
|
|
397
|
+
if (error instanceof Anthropic.RateLimitError || error instanceof Anthropic.InternalServerError || error instanceof Anthropic.RequestTimeoutError || error instanceof Anthropic.ConflictError) {
|
|
398
|
+
const after = Number(error.headers?.get("retry-after"));
|
|
399
|
+
return new AgentModelUnavailableError(described, Number.isFinite(after) && after > 0 ? { retryAfterMs: after * 1000 } : {});
|
|
400
|
+
}
|
|
401
|
+
// "Too long" has no error type of its own either.
|
|
402
|
+
if (error instanceof Anthropic.ContentTooLargeError || (error instanceof Anthropic.BadRequestError && /prompt is too long|context window|too many tokens/i.test(error.message))) {
|
|
403
|
+
return new AgentModelContextOverflowError(`${described} The request does not fit the model's context window.`);
|
|
404
|
+
}
|
|
405
|
+
return new AnthropicProviderError(described);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Claude through the official SDK (`@anthropic-ai/sdk`, installed by the
|
|
411
|
+
* application): Tools, structured answers, images and PDF files, streaming,
|
|
412
|
+
* prompt caching and adaptive thinking.
|
|
413
|
+
*
|
|
414
|
+
* ```ts
|
|
415
|
+
* @Infra({ llm: llmConnect(llmConfig, anthropicAdapter({ effort: "medium" })) })
|
|
416
|
+
* export class AppInfra {}
|
|
417
|
+
* ```
|
|
418
|
+
*/
|
|
419
|
+
export function anthropicAdapter(options: AnthropicAdapterOptions = {}): LlmProviderAdapter {
|
|
420
|
+
const healthCheckEnabled = options.healthCheck ?? true;
|
|
421
|
+
return {
|
|
422
|
+
create(connection) {
|
|
423
|
+
return new AnthropicModelProvider(connection, options);
|
|
424
|
+
},
|
|
425
|
+
async connect(provider, signal) {
|
|
426
|
+
if (!(provider instanceof AnthropicModelProvider)) return;
|
|
427
|
+
const deadline = AbortSignal.timeout(START_CHECK_TIMEOUT_MS);
|
|
428
|
+
try {
|
|
429
|
+
await provider.probe(signal === undefined ? deadline : AbortSignal.any([signal, deadline]));
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (signal?.aborted) throw error;
|
|
432
|
+
const reason = deadline.aborted ? `no answer within ${START_CHECK_TIMEOUT_MS} ms` : errorMessageOf(error);
|
|
433
|
+
throw new InfraError(`Infra connector "llm": ${provider.address || "Anthropic"} is not available — ${redactSensitiveText(reason)}`);
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
healthCheck: healthCheckEnabled
|
|
437
|
+
? (provider, signal) => provider instanceof AnthropicModelProvider && provider.ping(signal)
|
|
438
|
+
: undefined,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.139.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -24,11 +24,19 @@ export interface LlmConnectionOptions {
|
|
|
24
24
|
readonly apiKey: string;
|
|
25
25
|
/** Request timeout. Defaults to 60s; `0` explicitly disables it. */
|
|
26
26
|
readonly timeoutMs?: number;
|
|
27
|
+
/** The model's context window in tokens, when the application states it. */
|
|
28
|
+
readonly contextTokens?: number;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export interface LlmConnectorOptions {
|
|
30
32
|
/** Request timeout. Defaults to 60s; `0` explicitly disables it. */
|
|
31
33
|
readonly timeoutMs?: number;
|
|
34
|
+
/**
|
|
35
|
+
* The context window of the model, in tokens (128 000 for a 128k model).
|
|
36
|
+
* With it the agent runtime compacts a long dialog before the window is
|
|
37
|
+
* full; without it only when the provider refuses a request as too long.
|
|
38
|
+
*/
|
|
39
|
+
readonly contextTokens?: number;
|
|
32
40
|
/**
|
|
33
41
|
* Check the provider at start (the adapter's `connect`: `GET /models` for an
|
|
34
42
|
* OpenAI-compatible API, five seconds), so a wrong key or address stops the
|
|
@@ -58,7 +66,11 @@ export function resolveLlmConnectionOptions<T extends LlmConfigShape>(
|
|
|
58
66
|
if (!Number.isSafeInteger(configuredTimeout) || configuredTimeout < 0) {
|
|
59
67
|
throw new InfraError("llm timeoutMs must be a non-negative safe integer");
|
|
60
68
|
}
|
|
69
|
+
if (options.contextTokens !== undefined && (!Number.isSafeInteger(options.contextTokens) || options.contextTokens <= 0)) {
|
|
70
|
+
throw new InfraError("llm contextTokens must be a positive safe integer");
|
|
71
|
+
}
|
|
61
72
|
return {
|
|
73
|
+
...(options.contextTokens === undefined ? {} : { contextTokens: options.contextTokens }),
|
|
62
74
|
provider: requireValue(c.get("provider"), "provider", "llm"),
|
|
63
75
|
model: requireValue(c.get("model"), "model", "llm"),
|
|
64
76
|
baseUrl: requireValue(c.get("baseUrl"), "baseUrl", "llm"),
|
|
@@ -3,7 +3,9 @@ import type { HostedServiceDiagnostics } from "../../di";
|
|
|
3
3
|
import { redactSensitive } from "../../../library/redaction";
|
|
4
4
|
import {
|
|
5
5
|
AGENT_MODEL_PROVIDER,
|
|
6
|
+
AgentModelContextOverflowError,
|
|
6
7
|
AgentModelTimeoutError,
|
|
8
|
+
AgentModelUnavailableError,
|
|
7
9
|
AgentRuntime,
|
|
8
10
|
agentModelRequest,
|
|
9
11
|
type AgentModelProvider,
|
|
@@ -175,6 +177,7 @@ function normalizeProfileOptions(options: LlmModelProfileOptions, field: string)
|
|
|
175
177
|
const model = nonEmptyText(options.model, `${field}.model`);
|
|
176
178
|
const fallback = nonEmptyText(options.fallback, `${field}.fallback`);
|
|
177
179
|
nonNegativeInteger(options.timeoutMs, `${field}.timeoutMs`);
|
|
180
|
+
positiveInteger(options.contextTokens, `${field}.contextTokens`);
|
|
178
181
|
positiveInteger(options.maxOutputTokens, `${field}.maxOutputTokens`);
|
|
179
182
|
finiteNumber(options.temperature, `${field}.temperature`);
|
|
180
183
|
return Object.freeze({
|
|
@@ -224,6 +227,11 @@ export class LlmModelRouter implements AgentModelProvider {
|
|
|
224
227
|
return this.profileNames;
|
|
225
228
|
}
|
|
226
229
|
|
|
230
|
+
/** The context window of a profile's model, when its `llmProfile` states `contextTokens`. */
|
|
231
|
+
public contextTokens(modelProfile?: string): number | undefined {
|
|
232
|
+
return this.profiles.get(modelProfile ?? this.defaultProfile)?.connection.contextTokens;
|
|
233
|
+
}
|
|
234
|
+
|
|
227
235
|
/** The application logger: a fallback is reported, not silent. */
|
|
228
236
|
public useDiagnostics(diagnostics: HostedServiceDiagnostics): void {
|
|
229
237
|
this.diagnostics = diagnostics;
|
|
@@ -387,6 +395,10 @@ export class LlmModelRouter implements AgentModelProvider {
|
|
|
387
395
|
const message = `LLM model profile "${profileName}" failed: ${errorMessageOf(error)}`;
|
|
388
396
|
// Keep a timeout a timeout: the run reports it as AGENT_PROVIDER_TIMEOUT.
|
|
389
397
|
if (error instanceof AgentModelTimeoutError) throw new AgentModelTimeoutError(message);
|
|
398
|
+
// Keep "too long" what it is: the runtime compacts the dialog.
|
|
399
|
+
if (error instanceof AgentModelContextOverflowError) throw new AgentModelContextOverflowError(message);
|
|
400
|
+
// Keep "not now" what it is: the runtime repeats the request.
|
|
401
|
+
if (error instanceof AgentModelUnavailableError) throw new AgentModelUnavailableError(message, { retryAfterMs: error.retryAfterMs });
|
|
390
402
|
throw new LlmModelRouterError(message);
|
|
391
403
|
} finally {
|
|
392
404
|
acceptingText = false;
|