Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
a54f460
Add module skeleton, option validation
myovchev Jul 20, 2026
49c9de1
Activate providers, build effort table
myovchev Jul 20, 2026
ad8e778
Add modelInfo and route resolve logic
myovchev Jul 20, 2026
c1f5aa0
Merge pull request #5529 from apostrophecms/PRO-9727-engine
myovchev Jul 20, 2026
fd32404
Normalized shapes and request assembly
myovchev Jul 20, 2026
01f9977
Add call pipeline
myovchev Jul 20, 2026
6d93aa5
Retry policy and failure logs
myovchev Jul 20, 2026
066b2c7
Add mock mode
myovchev Jul 20, 2026
3eee7f3
Anthropic adapter
myovchev Jul 20, 2026
30ed7b0
Merge branch 'ai-adapters' into PRO-9729-generate
myovchev Jul 20, 2026
8558ace
Add Open AI adapter
myovchev Jul 21, 2026
55ec74d
Add gemini adapter
myovchev Jul 21, 2026
830e17d
Test zero code custom provider
myovchev Jul 21, 2026
d6ef596
Merge pull request #5530 from apostrophecms/PRO-9729-generate
myovchev Jul 21, 2026
639e3c3
Merge branch 'ai-adapters' into PRO-9730-adapters
myovchev Jul 21, 2026
7aac8a2
Add tools registry and validation, tool call
myovchev Jul 22, 2026
34bc56e
Generate loop with tool executions
myovchev Jul 22, 2026
cfcb52f
Merge pull request #5531 from apostrophecms/PRO-9730-adapters
myovchev Jul 22, 2026
85a66e8
Add structured response feature
myovchev Jul 22, 2026
a8d8899
Adapters tool translation
myovchev Jul 22, 2026
25db51c
Add structured response support per adapter
myovchev Jul 22, 2026
cbe48d5
Introduce openai compatible adapter
myovchev Jul 22, 2026
74ceefc
Add default OpenAI response API, support thinking blocks in the requests
myovchev Jul 22, 2026
50602a0
Pass back Anthropic thinking blocks
myovchev Jul 22, 2026
7b7ef3e
Carry Gemini thought signature after tool calls
myovchev Jul 22, 2026
4f8fcf6
Ensure compat OpenAI adapter handles properly tools plus effort edge …
myovchev Jul 22, 2026
d761e8d
AI permission proxy
myovchev Jul 22, 2026
11a7eb5
Merge branch 'ai-adapters' into PRO-9731-structured
myovchev Jul 22, 2026
7db5a55
Fix rounding bug where upper exclusive bound can be reached.
myovchev Jul 22, 2026
2da27ae
Merge pull request #5534 from apostrophecms/PRO-9731-structured
myovchev Jul 23, 2026
0dcbfa3
Add core image options resolution, ref cleanup
myovchev Jul 23, 2026
ec731f5
Open AI image generation
myovchev Jul 23, 2026
4121afb
Add Google image generation
myovchev Jul 23, 2026
e5a64b9
Add generate image
myovchev Jul 23, 2026
c6ed413
Add req argument for the custom mock functions
myovchev Jul 23, 2026
fb99693
Add job cancellation, ownership, error payload and TTL support
myovchev Jul 24, 2026
255b9c1
Add generateJob with hooks and cooperative cancellation
myovchev Jul 24, 2026
cf6fe91
Revert back to DB layer job expire
myovchev Jul 24, 2026
945823c
Add AI jobs notification publisher
myovchev Jul 24, 2026
9a93d69
Add cross-process cancel testing
myovchev Jul 24, 2026
dfa456c
Add first-class notification bus flag
myovchev Jul 24, 2026
889c658
Merge pull request #5536 from apostrophecms/PRO-9732-image
myovchev Jul 24, 2026
01bf3a0
Merge branch 'ai-adapters' into PRO-9733-jobs
myovchev Jul 24, 2026
2048fbc
Fix a BC break
myovchev Jul 24, 2026
a6ae2b9
Fix unrelated possible test failure
myovchev Jul 24, 2026
daafbc6
Fix job testing race
myovchev Jul 24, 2026
f6bee72
Merge pull request #5537 from apostrophecms/PRO-9733-jobs
myovchev Jul 24, 2026
dc0e175
refactor 01
myovchev Jul 27, 2026
3d5a235
refactor 02
myovchev Jul 27, 2026
4ac231b
refactor 03
myovchev Jul 27, 2026
c585eb3
refactor 04
myovchev Jul 27, 2026
f4caaa7
refactor 05
myovchev Jul 27, 2026
49c1ef5
refactor 06a
myovchev Jul 27, 2026
8066ed0
refactor 06 final sweep
myovchev Jul 27, 2026
47a716a
refactor 07
myovchev Jul 27, 2026
288aa55
refactor 08
myovchev Jul 27, 2026
514ac72
refactor 09
myovchev Jul 27, 2026
0fa07b3
refactor 10
myovchev Jul 27, 2026
a09870e
Merge pull request #5539 from apostrophecms/ai-cl-refactoring
myovchev Jul 28, 2026
cfcf4e0
Add document extract feature
myovchev Jul 28, 2026
722b8c5
Add parity test
myovchev Jul 28, 2026
741a024
Merge pull request #5540 from apostrophecms/PRO-9735-at
myovchev Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/apostrophe/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ module.exports = {
'@apostrophecms/login': {},
'@apostrophecms/doc': {},
'@apostrophecms/job': {},
'@apostrophecms/ai': {},
'@apostrophecms/ai-adapter-anthropic': {},
'@apostrophecms/ai-adapter-openai': {},
'@apostrophecms/ai-adapter-openai-compatible': {},
'@apostrophecms/ai-adapter-google': {},
'@apostrophecms/modal': {},
'@apostrophecms/oembed': {},
'@apostrophecms/pager': {},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
// The standard Anthropic (Claude) adapter for `apos.ai`. It registers
// itself with the AI engine at startup; configure the provider with just
// a key under the engine's `providers.anthropic` entry to use it. All
// knowledge of the Anthropic Messages dialect — request translation,
// prompt-cache markers, response parsing, error mapping - lives here.
//
// The transport is `apos.http`, no SDK. Projects can adjust the dialect
// by extending this module and overriding its methods.

// Anthropic has no response-schema mode, so structured output is
// delivered through tool use: a synthetic tool whose input schema is
// the request's `schema`. The model calls it to answer; parseResponse
// turns that call back into a plain structured answer. Its name leads
// with an underscore, which the engine's tool-name rule forbids, so
// it can never collide with a real tool.
const FINAL_ANSWER = '_final_answer';
const FINAL_ANSWER_DESCRIPTION = 'Provide your final answer by calling this tool with the required fields. This is the only way to return your response.';

module.exports = {
options: {
// The anthropic-version request header
version: '2023-06-01',
// Per-request timeout in milliseconds; a timed-out call is a
// transient failure the engine retries
timeout: 600000,
// reasoning level - extended-thinking budget_tokens (Anthropic's
// floor for a budget is 1024).
thinkingBudgets: {
low: 1024,
medium: 4096,
high: 16384
}
},
init(self) {
self.apos.ai.addAdapter(self.adapter());
},
methods(self) {
return {
// The adapter definition registered with `apos.ai`. The engine
// instantiates it per configured provider entry, assigning
// `provider`, `apiKey` and `baseUrl` — which is why `chat` and
// `validate` read config from `this` while the dialect work
// delegates to the module's methods.
adapter() {
return {
name: 'anthropic',
label: 'Anthropic (Claude)',
baseUrl: 'https://api.anthropic.com',
envKey: 'APOS_ANTHROPIC_KEY',
capabilities: {
text: true,
tools: true,
structured: true,
stream: true,
imageInput: true,
image: false,
caching: true
},
effort: {
low: { model: 'claude-haiku-4-5' },
medium: { model: 'claude-sonnet-4-6' },
high: {
model: 'claude-opus-4-8',
reasoning: 'high'
}
},
models: {
'claude-haiku-4-5': {
contextWindow: 200000,
maxOutputTokens: 32000
},
'claude-sonnet-4-6': {
contextWindow: 200000,
maxOutputTokens: 64000
},
'claude-opus-4-8': {
contextWindow: 200000,
maxOutputTokens: 64000
}
},
validate() {
self.apos.ai.requireApiKey(this);
},
async chat(req, request) {
const response = await self.apos.http.post(`${this.baseUrl}/v1/messages`, {
headers: {
'x-api-key': this.apiKey,
'anthropic-version': self.options.version
},
body: self.buildBody(request),
timeout: self.options.timeout,
...(request.signal && { signal: request.signal })
});
return self.parseResponse(response, request);
},
normalizeError(error) {
return self.normalizeError(error);
}
};
},
// Translate a normalized adapter request (see the engine's
// buildRequest) to an Anthropic Messages API body: content parts
// become Anthropic blocks, tool definitions become `tools`,
// `reasoning` becomes a thinking budget, and the cache policy is
// placed as `cache_control` markers — one on the system tail (the
// static prefix) and a rolling one on the last message, so the
// next call in a conversation reads what this one wrote. Tool
// requests and results ride the conversation as `tool_use` and
// `tool_result` blocks; the dialect has no tool role, so a
// normalized `tool` message becomes a `user` message of
// `tool_result` blocks. This adapter's own opaque `thinking`
// parts are replayed as the raw signed blocks they carry, ahead
// of the turn's other blocks — Anthropic requires an assistant
// turn's thinking preserved unmodified when its tool results come
// back. Part types this dialect does not own are skipped. A
// structured-output `schema` adds the synthetic final-answer
// tool, forced when nothing competes for the turn. Throws
// "invalid" on requests the dialect cannot express.
buildBody(request) {
const invalid = (message) => {
throw self.apos.error('invalid', message);
};
const {
system, messages, model, maxTokens, reasoning, cache, tools, schema
} = request;
if (!Number.isInteger(maxTokens)) {
invalid(`"maxTokens" is required: model "${model}" declares no maxOutputTokens to default to`);
}
const wireTools = [
...(tools || []).map(toTool),
...(schema
? [ {
name: FINAL_ANSWER,
description: FINAL_ANSWER_DESCRIPTION,
input_schema: schema
} ]
: [])
];
const body = {
model,
max_tokens: maxTokens,
...(system !== undefined && { system }),
...(wireTools.length && { tools: wireTools }),
// Force the structured answer only when nothing else needs the
// turn: a real tool the model must be free to call first, or
// extended thinking, which Anthropic forbids alongside a forced
// tool. Otherwise the tool's description drives it and the
// engine's backstop retries a miss.
...(schema && !(tools && tools.length) && reasoning === undefined && {
tool_choice: {
type: 'tool',
name: FINAL_ANSWER
}
}),
messages: messages.map((message) => ({
role: message.role === 'tool' ? 'user' : message.role,
content: message.content.map(toBlock).filter(Boolean)
}))
};
if (reasoning !== undefined) {
body.thinking = toThinking(reasoning);
}
if (cache) {
const marker = {
type: 'ephemeral',
...(cache.ttl === 'long' && { ttl: '1h' })
};
if (body.system !== undefined) {
body.system = [ {
type: 'text',
text: body.system,
cache_control: marker
} ];
}
const parts = body.messages.at(-1).content;
parts[parts.length - 1].cache_control = marker;
}
return body;

// The model-facing tool definition; the JSON Schema travels
// verbatim as input_schema
function toTool(tool) {
return {
name: tool.name,
description: tool.description,
input_schema: tool.input
};
}
function toBlock(part) {
if (part.type === 'text') {
return {
type: 'text',
text: part.text
};
}
if (part.type === 'toolCall') {
return {
type: 'tool_use',
id: part.id,
name: part.name,
input: part.input
};
}
if (part.type === 'toolResult') {
// Anthropic carries the result as a string; an object output
// is serialized, an error is flagged
return {
type: 'tool_result',
tool_use_id: part.toolCallId,
...(part.error !== undefined
? {
content: part.error,
is_error: true
}
: { content: JSON.stringify(part.output) })
};
}
// The raw signed thinking block this adapter's parseResponse
// carried over, replayed verbatim
if (part.type === 'thinking') {
return part.block;
}
if (part.type === 'image') {
return {
type: 'image',
source: part.image.url !== undefined
? {
type: 'url',
url: part.image.url
}
: {
type: 'base64',
media_type: part.image.mediaType,
data: part.image.data
}
};
}
// Another dialect's part; not ours to translate
return null;
}
// Anthropic wants an absolute token budget, mapped by the
// thinkingBudgets option; the budget must leave max_tokens
// room for the answer
function toThinking(reasoning) {
const budget = self.options.thinkingBudgets[reasoning];
if (!budget) {
invalid(`no thinking budget is configured for reasoning "${reasoning}"`);
}
if (maxTokens <= budget) {
invalid(`"maxTokens" (${maxTokens}) must exceed the "${reasoning}" thinking budget (${budget})`);
}
return {
type: 'enabled',
budget_tokens: budget
};
}
},
// Translate an Anthropic Messages response to the normalized
// assistant turn { content, finishReason, usage, model }. A
// thinking (or redacted thinking) block rides along as this
// adapter's opaque `thinking` part carrying the raw signed block:
// Anthropic requires it back, unmodified, when the turn's tool
// results are submitted, so the loop replays what buildBody then
// restores. A `refusal` stop reason throws the refusal error
// here, so "refused" always arrives as an error. When the request asked for
// structured output and the model called the synthetic
// final-answer tool, that call is the answer, not a tool for the
// core to run: it becomes a `stop` turn carrying the arguments on
// `object` (and their JSON in the text, so the transcript
// round-trips). Anything else — free text, a real tool call —
// parses normally, leaving no `object` for the engine backstop to
// retry on. An unknown stop reason maps to no finishReason — the
// engine's turn validation treats that as a malformed (retryable)
// response, never a truncated success.
parseResponse(response, request = {}) {
if (response.stop_reason === 'refusal') {
throw self.apos.error('aiRefusal', 'the model refused this request');
}
const usage = {
inputTokens: response.usage?.input_tokens,
outputTokens: response.usage?.output_tokens
};
const content = (response.content || [])
.map(fromBlock)
.filter(Boolean);
if (request.schema) {
const answer = content.find(
(part) => part.type === 'toolCall' && part.name === FINAL_ANSWER
);
if (answer) {
return {
content: [ {
type: 'text',
text: JSON.stringify(answer.input)
} ],
object: answer.input,
finishReason: 'stop',
usage,
model: response.model
};
}
}
return {
content,
finishReason: {
end_turn: 'stop',
stop_sequence: 'stop',
max_tokens: 'length',
tool_use: 'toolCalls'
}[response.stop_reason],
usage,
model: response.model
};

function fromBlock(block) {
if (block.type === 'text') {
return {
type: 'text',
text: block.text
};
}
if (block.type === 'tool_use') {
return {
type: 'toolCall',
id: block.id,
name: block.name,
input: block.input
};
}
if (block.type === 'thinking' || block.type === 'redacted_thinking') {
return {
type: 'thinking',
block
};
}
return null;
}
},
// Map any error the transport produced to a normalized apos
// error, the only shape the engine reacts to: the engine's shared
// status ladder, plus the one fact that is this service's own —
// its request id rides the `request-id` header.
normalizeError(error) {
return self.apos.ai.normalizeHttpError(error, {
requestIdHeader: 'request-id'
});
}
};
}
};
Loading
Loading