@kindgi/adapter-model-anthropic 0.0.0-bootstrap.0 → 0.1.1

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.
@@ -0,0 +1,201 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type Anthropic from '@anthropic-ai/sdk';
5
+
6
+ import type {
7
+ ModelCallResult,
8
+ ModelMessage,
9
+ ModelToolCall,
10
+ ModelToolDefinition,
11
+ } from '@kindgi/capabilities';
12
+
13
+ /**
14
+ * The framework's tool id convention is `<pack>.<tool>` — e.g.
15
+ * `acme.orders.lookup`. Anthropic's Messages API rejects tool
16
+ * names that don't match `^[a-zA-Z0-9_-]{1,128}$` (in particular,
17
+ * dots are forbidden), so this adapter transparently encodes tool
18
+ * names on the way out and decodes them on the way in.
19
+ *
20
+ * Encoding is a simple `.` → `__` substitution. Reversible as long
21
+ * as authors don't put literal `__` in their tool ids — which the
22
+ * framework's kebab-case convention already discourages. The
23
+ * substitution is contained to the wire boundary; the message trail,
24
+ * runtime registry, and provenance records all continue to see the
25
+ * canonical `<pack>.<tool>` form.
26
+ */
27
+ export function encodeToolName(name: string): string {
28
+ return name.replace(/\./g, '__');
29
+ }
30
+
31
+ export function decodeToolName(name: string): string {
32
+ return name.replace(/__/g, '.');
33
+ }
34
+
35
+ /**
36
+ * Translate a framework message trail to the Anthropic Messages API
37
+ * shape. Two structural mappings matter:
38
+ *
39
+ * - `role: 'system'` messages lift out of the message array into
40
+ * Anthropic's top-level `system` parameter. Multiple system
41
+ * messages concatenate with a blank-line separator, preserving
42
+ * order — the framework's message-trail contract lets any handler
43
+ * push a system message, so we don't assume there's only one.
44
+ * - `role: 'tool'` messages fold into `role: 'user'` messages
45
+ * carrying `tool_result` content blocks. Anthropic requires tool
46
+ * results to arrive as user turns; consecutive tool results
47
+ * merge into a single user message (matching the framework's
48
+ * dispatch-tools handler which appends one tool message per call
49
+ * after an assistant `tool_use` turn).
50
+ */
51
+ export function toAnthropicMessages(messages: readonly ModelMessage[]): {
52
+ readonly system: string | undefined;
53
+ readonly messages: readonly Anthropic.MessageParam[];
54
+ } {
55
+ const systemParts: string[] = [];
56
+ const out: Anthropic.MessageParam[] = [];
57
+
58
+ for (const msg of messages) {
59
+ if (msg.role === 'system') {
60
+ if (msg.content.length > 0) systemParts.push(msg.content);
61
+ continue;
62
+ }
63
+ if (msg.role === 'user') {
64
+ appendToUserTurn(out, [{ type: 'text', text: msg.content }]);
65
+ continue;
66
+ }
67
+ if (msg.role === 'assistant') {
68
+ const blocks: Anthropic.ContentBlockParam[] = [];
69
+ if (msg.content.length > 0) blocks.push({ type: 'text', text: msg.content });
70
+ if (msg.toolCalls !== undefined) {
71
+ for (const tc of msg.toolCalls) {
72
+ blocks.push({
73
+ type: 'tool_use',
74
+ id: tc.id,
75
+ // Encode when replaying prior assistant turns — the
76
+ // message trail stores canonical framework names, but
77
+ // Anthropic on the wire wants the encoded form.
78
+ name: encodeToolName(tc.name),
79
+ input: tc.arguments,
80
+ });
81
+ }
82
+ }
83
+ // Anthropic rejects assistant turns with empty content — skip
84
+ // rather than emit an invalid message. The framework's
85
+ // dispatch-tools handler ensures either text or tool_use is
86
+ // present, so this only triggers on malformed input.
87
+ if (blocks.length > 0) {
88
+ out.push({ role: 'assistant', content: blocks });
89
+ }
90
+ continue;
91
+ }
92
+ // role === 'tool'
93
+ const toolResult: Anthropic.ToolResultBlockParam = {
94
+ type: 'tool_result',
95
+ tool_use_id: msg.toolCallId ?? '',
96
+ content: msg.content,
97
+ };
98
+ appendToUserTurn(out, [toolResult]);
99
+ }
100
+
101
+ return {
102
+ system: systemParts.length > 0 ? systemParts.join('\n\n') : undefined,
103
+ messages: out,
104
+ };
105
+ }
106
+
107
+ function appendToUserTurn(
108
+ out: Anthropic.MessageParam[],
109
+ blocks: readonly Anthropic.ContentBlockParam[],
110
+ ): void {
111
+ const last = out[out.length - 1];
112
+ if (last !== undefined && last.role === 'user') {
113
+ const existing = normalizeContent(last.content);
114
+ last.content = [...existing, ...blocks];
115
+ return;
116
+ }
117
+ out.push({ role: 'user', content: [...blocks] });
118
+ }
119
+
120
+ function normalizeContent(
121
+ content: Anthropic.MessageParam['content'],
122
+ ): Anthropic.ContentBlockParam[] {
123
+ if (typeof content === 'string') return [{ type: 'text', text: content }];
124
+ return [...content];
125
+ }
126
+
127
+ /**
128
+ * Translate framework tool definitions to Anthropic's `tools` shape.
129
+ * `input_schema` is JSON-Schema-shaped on both sides — direct passthrough.
130
+ */
131
+ export function toAnthropicTools(tools: readonly ModelToolDefinition[]): readonly Anthropic.Tool[] {
132
+ return tools.map((t) => ({
133
+ name: encodeToolName(t.name),
134
+ description: t.description,
135
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
136
+ }));
137
+ }
138
+
139
+ /**
140
+ * Translate an Anthropic response message back into a framework
141
+ * `ModelMessage`. Text blocks concatenate into `content`; tool_use
142
+ * blocks become `toolCalls`. Thinking blocks are ignored (the
143
+ * framework has no thinking field on `ModelMessage`).
144
+ */
145
+ export function fromAnthropicResponse(response: Anthropic.Message): ModelMessage {
146
+ const textParts: string[] = [];
147
+ const toolCalls: ModelToolCall[] = [];
148
+
149
+ for (const block of response.content) {
150
+ if (block.type === 'text') {
151
+ textParts.push(block.text);
152
+ } else if (block.type === 'tool_use') {
153
+ toolCalls.push({
154
+ id: block.id,
155
+ // Decode: Anthropic returns the encoded name (`weather__forecast`);
156
+ // the rest of the framework expects the canonical dotted form.
157
+ name: decodeToolName(block.name),
158
+ arguments: (block.input ?? {}) as Readonly<Record<string, unknown>>,
159
+ });
160
+ }
161
+ // 'thinking' / 'redacted_thinking' blocks are ignored (see above).
162
+ }
163
+
164
+ const message: ModelMessage = {
165
+ role: 'assistant',
166
+ content: textParts.join(''),
167
+ ...(toolCalls.length > 0 && { toolCalls }),
168
+ };
169
+ return message;
170
+ }
171
+
172
+ /**
173
+ * Map Anthropic's `stop_reason` to the framework's `finishReason`.
174
+ *
175
+ * Mapping choices:
176
+ * - `end_turn`, `stop_sequence`, `pause_turn` → `stop`
177
+ * (`pause_turn` is a server-side agentic pause — non-streaming
178
+ * invokers don't resume, so treat as terminal for this call.)
179
+ * - `max_tokens` → `length`
180
+ * - `tool_use` → `tool-use`
181
+ * - `refusal` → `content-filter`
182
+ * - `null` (streaming edge) / unknown → `stop`
183
+ */
184
+ export function mapStopReason(
185
+ reason: Anthropic.Message['stop_reason'],
186
+ ): ModelCallResult['finishReason'] {
187
+ switch (reason) {
188
+ case 'end_turn':
189
+ case 'stop_sequence':
190
+ case 'pause_turn':
191
+ return 'stop';
192
+ case 'max_tokens':
193
+ return 'length';
194
+ case 'tool_use':
195
+ return 'tool-use';
196
+ case 'refusal':
197
+ return 'content-filter';
198
+ default:
199
+ return 'stop';
200
+ }
201
+ }