@moxxy/plugin-provider-anthropic 0.2.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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Real-API smoke test.
3
+ *
4
+ * Drives the FULL moxxy stack — AnthropicProvider translator → loop strategy
5
+ * → tool execution → event log projection — using a hand-authored Anthropic
6
+ * stream fixture that mirrors the wire shape we'd record from a live API call.
7
+ *
8
+ * The fixture isn't generated by hitting the real API at test time; it's
9
+ * captured shape that exercises every translation path. To regenerate from
10
+ * a real run, use `apps/fixture-recorder` with MOXXY_FIXTURES=record.
11
+ */
12
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
13
+ import { promises as fs } from 'node:fs';
14
+ import * as os from 'node:os';
15
+ import * as path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { z } from 'zod';
18
+ import {
19
+ collectTurn,
20
+ silentLogger,
21
+ Session,
22
+ autoAllowResolver,
23
+ } from '@moxxy/core';
24
+ import { defineProvider, definePlugin, defineTool } from '@moxxy/sdk';
25
+ import { defaultModePlugin } from '@moxxy/mode-default';
26
+ import { AnthropicProvider } from './provider.js';
27
+
28
+ const __filename = fileURLToPath(import.meta.url);
29
+ const __dirname = path.dirname(__filename);
30
+
31
+ interface AnthropicStreamFixture {
32
+ description: string;
33
+ request: unknown;
34
+ events: ReadonlyArray<Record<string, unknown>>;
35
+ }
36
+
37
+ /** Replays a fixture's raw Anthropic events as a fake SDK client. */
38
+ function fakeAnthropicClient(fixture: AnthropicStreamFixture): {
39
+ messages: {
40
+ stream: () => AsyncIterable<unknown>;
41
+ countTokens: () => Promise<{ input_tokens: number }>;
42
+ };
43
+ } {
44
+ return {
45
+ messages: {
46
+ stream: () =>
47
+ (async function* () {
48
+ for (const e of fixture.events) yield e;
49
+ })(),
50
+ countTokens: async () => ({ input_tokens: 0 }),
51
+ },
52
+ };
53
+ }
54
+
55
+ let tmp: string;
56
+
57
+ beforeEach(async () => {
58
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-realapi-'));
59
+ });
60
+ afterEach(async () => {
61
+ await fs.rm(tmp, { recursive: true, force: true });
62
+ });
63
+
64
+ describe('real-API smoke: tool-use turn replayed end-to-end', () => {
65
+ it('translates raw Anthropic events through the full moxxy stack', async () => {
66
+ const fixturePath = path.resolve(__dirname, '..', '__fixtures__', 'tool-use-turn.json');
67
+ const fixture: AnthropicStreamFixture = JSON.parse(await fs.readFile(fixturePath, 'utf8'));
68
+
69
+ const upstream = new AnthropicProvider({
70
+ client: fakeAnthropicClient(fixture) as never,
71
+ });
72
+
73
+ // Need a second-turn reply for the model's summary AFTER the tool result.
74
+ // For this smoke we only assert on the first translation pass, so wire a
75
+ // shim that exits the loop on its second invocation.
76
+ let callCount = 0;
77
+ const provider = {
78
+ name: upstream.name,
79
+ models: upstream.models,
80
+ async *stream(req: import('@moxxy/sdk').ProviderRequest) {
81
+ callCount += 1;
82
+ if (callCount === 1) {
83
+ yield* upstream.stream(req);
84
+ return;
85
+ }
86
+ // Second call: emit a clean end_turn so the loop terminates.
87
+ yield { type: 'message_start' as const, model: req.model };
88
+ yield { type: 'text_delta' as const, delta: 'OK — greeted world.' };
89
+ yield { type: 'message_end' as const, stopReason: 'end_turn' as const };
90
+ },
91
+ async countTokens() {
92
+ return 0;
93
+ },
94
+ };
95
+
96
+ const session = new Session({
97
+ cwd: tmp,
98
+ logger: silentLogger,
99
+ permissionResolver: autoAllowResolver,
100
+ });
101
+ session.pluginHost.registerStatic(
102
+ definePlugin({
103
+ name: 'realapi-shim',
104
+ providers: [
105
+ defineProvider({
106
+ name: 'anthropic',
107
+ models: [...upstream.models],
108
+ createClient: () => provider,
109
+ }),
110
+ ],
111
+ }),
112
+ );
113
+ session.providers.setActive('anthropic');
114
+ session.pluginHost.registerStatic(defaultModePlugin);
115
+ session.pluginHost.registerStatic(
116
+ definePlugin({
117
+ name: 'realapi-tools',
118
+ tools: [
119
+ defineTool({
120
+ name: 'greet',
121
+ description: 'Returns a greeting for the given name.',
122
+ inputSchema: z.object({ name: z.string() }),
123
+ handler: ({ name }) => `Hello, ${name}!`,
124
+ }),
125
+ ],
126
+ }),
127
+ );
128
+
129
+ const events = await collectTurn(session, 'please greet world');
130
+
131
+ // Phase 1: AnthropicProvider correctly translated the raw stream into a
132
+ // tool_use event sequence with the streamed input JSON re-assembled.
133
+ const toolStart = events.find((e) => e.type === 'tool_call_requested');
134
+ if (toolStart?.type !== 'tool_call_requested') throw new Error('expected tool_call_requested');
135
+ expect(toolStart.name).toBe('greet');
136
+ expect(toolStart.input).toEqual({ name: 'world' });
137
+ expect(toolStart.callId).toBe('toolu_01XYZ');
138
+
139
+ // Phase 2: the tool executed and the result fed back into the loop.
140
+ const toolResult = events.find((e) => e.type === 'tool_result');
141
+ if (toolResult?.type !== 'tool_result') throw new Error('expected tool_result');
142
+ expect(toolResult.ok).toBe(true);
143
+ expect(toolResult.output).toBe('Hello, world!');
144
+
145
+ // Phase 3: model produced a final summary in the second iteration.
146
+ const finalAssistant = events.filter((e) => e.type === 'assistant_message').pop();
147
+ if (finalAssistant?.type !== 'assistant_message') throw new Error('expected assistant_message');
148
+ expect(finalAssistant.content).toContain('greeted');
149
+
150
+ // The translator emitted exactly one provider request (not retried).
151
+ const providerRequests = events.filter((e) => e.type === 'provider_request');
152
+ expect(providerRequests.length).toBe(2); // one per loop iteration
153
+ });
154
+ });
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { defineTool } from '@moxxy/sdk';
4
+ import { toAnthropicMessages, toAnthropicTools } from './translate.js';
5
+
6
+ describe('toAnthropicMessages', () => {
7
+ it('hoists system messages', () => {
8
+ const { system, messages } = toAnthropicMessages([
9
+ { role: 'system', content: [{ type: 'text', text: 'you are X' }] },
10
+ { role: 'user', content: [{ type: 'text', text: 'hi' }] },
11
+ ]);
12
+ expect(system).toBe('you are X');
13
+ expect(messages).toHaveLength(1);
14
+ expect(messages[0].role).toBe('user');
15
+ });
16
+
17
+ it('translates assistant tool_use blocks', () => {
18
+ const { messages } = toAnthropicMessages([
19
+ { role: 'user', content: [{ type: 'text', text: 'do it' }] },
20
+ {
21
+ role: 'assistant',
22
+ content: [{ type: 'tool_use', id: 'c1', name: 'Read', input: { file_path: 'a' } }],
23
+ },
24
+ ]);
25
+ expect(messages[1].role).toBe('assistant');
26
+ expect(messages[1].content[0]).toMatchObject({ type: 'tool_use', id: 'c1', name: 'Read' });
27
+ });
28
+
29
+ it('translates an image block to a base64 image source', () => {
30
+ const { messages } = toAnthropicMessages([
31
+ {
32
+ role: 'user',
33
+ content: [
34
+ { type: 'text', text: 'what is this?' },
35
+ { type: 'image', mediaType: 'image/png', data: 'AAAA' },
36
+ ],
37
+ },
38
+ ]);
39
+ expect(messages[0].content[1]).toEqual({
40
+ type: 'image',
41
+ source: { type: 'base64', media_type: 'image/png', data: 'AAAA' },
42
+ });
43
+ });
44
+
45
+ it('translates a document block to a native base64 document with title', () => {
46
+ const { messages } = toAnthropicMessages([
47
+ {
48
+ role: 'user',
49
+ content: [
50
+ { type: 'text', text: 'summarize this' },
51
+ { type: 'document', mediaType: 'application/pdf', data: 'JVBERi0=', name: 'report.pdf' },
52
+ ],
53
+ },
54
+ ]);
55
+ expect(messages[0].content[1]).toEqual({
56
+ type: 'document',
57
+ source: { type: 'base64', media_type: 'application/pdf', data: 'JVBERi0=' },
58
+ title: 'report.pdf',
59
+ });
60
+ });
61
+
62
+ it('merges adjacent tool_result messages into a user message', () => {
63
+ const { messages } = toAnthropicMessages([
64
+ { role: 'user', content: [{ type: 'text', text: 'q' }] },
65
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'c1', name: 'T', input: {} }] },
66
+ {
67
+ role: 'tool_result',
68
+ content: [{ type: 'tool_result', toolUseId: 'c1', content: 'ok', isError: false }],
69
+ },
70
+ ]);
71
+ const last = messages[messages.length - 1];
72
+ expect(last.role).toBe('user');
73
+ expect(last.content[0]).toMatchObject({
74
+ type: 'tool_result',
75
+ tool_use_id: 'c1',
76
+ content: 'ok',
77
+ });
78
+ });
79
+
80
+ it('places cache_control on the last block of a hinted message', () => {
81
+ const { messages } = toAnthropicMessages(
82
+ [
83
+ { role: 'user', content: [{ type: 'text', text: 'a' }] },
84
+ { role: 'assistant', content: [{ type: 'text', text: 'b' }] },
85
+ { role: 'user', content: [{ type: 'text', text: 'c' }] },
86
+ ],
87
+ { cacheMessageIndices: new Set([2]) },
88
+ );
89
+ const marked = messages[messages.length - 1]!.content[0]!;
90
+ expect(marked.cache_control).toEqual({ type: 'ephemeral' });
91
+ // Unhinted messages stay unmarked.
92
+ expect(messages[0]!.content[0]!.cache_control).toBeUndefined();
93
+ });
94
+
95
+ it('adds no cache_control when no hints are given', () => {
96
+ const { messages } = toAnthropicMessages([
97
+ { role: 'user', content: [{ type: 'text', text: 'a' }] },
98
+ ]);
99
+ expect(messages[0]!.content[0]!.cache_control).toBeUndefined();
100
+ });
101
+ });
102
+
103
+ describe('toAnthropicTools', () => {
104
+ it('emits name + description + json schema', () => {
105
+ const tool = defineTool({
106
+ name: 'Greet',
107
+ description: 'Greet someone',
108
+ inputSchema: z.object({ name: z.string() }),
109
+ handler: () => null,
110
+ });
111
+ const out = toAnthropicTools([tool]);
112
+ expect(out[0].name).toBe('Greet');
113
+ expect(out[0].description).toBe('Greet someone');
114
+ const schema = out[0].input_schema as Record<string, unknown>;
115
+ expect(schema.type).toBe('object');
116
+ });
117
+
118
+ it('marks the last tool with cache_control when cacheLast is set', () => {
119
+ const mk = (name: string) =>
120
+ defineTool({ name, description: name, inputSchema: z.object({}), handler: () => null });
121
+ const out = toAnthropicTools([mk('A'), mk('B')], { cacheLast: true });
122
+ expect(out[0].cache_control).toBeUndefined();
123
+ expect(out[1].cache_control).toEqual({ type: 'ephemeral' });
124
+ });
125
+ });
@@ -0,0 +1,194 @@
1
+ import type { ContentBlock, ProviderMessage, ToolDef } from '@moxxy/sdk';
2
+ import { zodToJsonSchema } from '@moxxy/sdk';
3
+
4
+ type CacheControl = { type: 'ephemeral' };
5
+
6
+ export interface AnthropicMessageInput {
7
+ role: 'user' | 'assistant';
8
+ content: Array<AnthropicContentBlock>;
9
+ }
10
+
11
+ export type AnthropicContentBlock =
12
+ | { type: 'text'; text: string; cache_control?: CacheControl }
13
+ | { type: 'tool_use'; id: string; name: string; input: unknown; cache_control?: CacheControl }
14
+ | {
15
+ type: 'tool_result';
16
+ tool_use_id: string;
17
+ content: string;
18
+ is_error?: boolean;
19
+ cache_control?: CacheControl;
20
+ }
21
+ | {
22
+ type: 'image';
23
+ source: { type: 'base64'; media_type: string; data: string };
24
+ cache_control?: CacheControl;
25
+ }
26
+ | {
27
+ type: 'document';
28
+ source: { type: 'base64'; media_type: string; data: string };
29
+ title?: string;
30
+ cache_control?: CacheControl;
31
+ }
32
+ // Reasoning round-trip: a signed `thinking` block (replayed verbatim on the
33
+ // same model so Anthropic accepts an interleaved-thinking tool-use turn) or a
34
+ // `redacted_thinking` block carrying only the opaque encrypted blob.
35
+ | { type: 'thinking'; thinking: string; signature: string; cache_control?: CacheControl }
36
+ | { type: 'redacted_thinking'; data: string; cache_control?: CacheControl };
37
+
38
+ export interface AnthropicToolDef {
39
+ name: string;
40
+ description: string;
41
+ input_schema: unknown;
42
+ cache_control?: CacheControl;
43
+ }
44
+
45
+ export interface ToAnthropicMessagesOptions {
46
+ /**
47
+ * Indices (into the input `messages` array) after which a prompt-cache
48
+ * breakpoint should be placed. The marker lands on the last Anthropic
49
+ * content block produced for that source message.
50
+ */
51
+ readonly cacheMessageIndices?: ReadonlySet<number>;
52
+ }
53
+
54
+ function markCache(block: AnthropicContentBlock | undefined): void {
55
+ if (block) block.cache_control = { type: 'ephemeral' };
56
+ }
57
+
58
+ export function toAnthropicMessages(
59
+ messages: ReadonlyArray<ProviderMessage>,
60
+ opts: ToAnthropicMessagesOptions = {},
61
+ ): {
62
+ system: string | undefined;
63
+ messages: AnthropicMessageInput[];
64
+ } {
65
+ const cacheIdx = opts.cacheMessageIndices;
66
+ let system: string | undefined;
67
+ const out: AnthropicMessageInput[] = [];
68
+ let pendingUserBlocks: AnthropicContentBlock[] | null = null;
69
+ const flushUser = (): void => {
70
+ if (pendingUserBlocks) {
71
+ out.push({ role: 'user', content: pendingUserBlocks });
72
+ pendingUserBlocks = null;
73
+ }
74
+ };
75
+
76
+ messages.forEach((msg, i) => {
77
+ const wantCache = cacheIdx?.has(i) ?? false;
78
+
79
+ if (msg.role === 'system') {
80
+ const textBlock = msg.content.find((c) => c.type === 'text');
81
+ if (textBlock && textBlock.type === 'text') {
82
+ system = system ? `${system}\n\n${textBlock.text}` : textBlock.text;
83
+ }
84
+ return;
85
+ }
86
+
87
+ if (msg.role === 'user') {
88
+ flushUser();
89
+ const content = msg.content.map(toAnthropicBlock);
90
+ if (wantCache) markCache(content[content.length - 1]);
91
+ out.push({ role: 'user', content });
92
+ return;
93
+ }
94
+
95
+ if (msg.role === 'assistant') {
96
+ flushUser();
97
+ const content = msg.content.map(toAnthropicBlock);
98
+ if (wantCache) markCache(content[content.length - 1]);
99
+ out.push({ role: 'assistant', content });
100
+ return;
101
+ }
102
+
103
+ if (msg.role === 'tool_result') {
104
+ // Tool results are merged into a user message with tool_result content blocks
105
+ pendingUserBlocks ??= [];
106
+ for (const block of msg.content) {
107
+ if (block.type === 'tool_result') {
108
+ pendingUserBlocks.push({
109
+ type: 'tool_result',
110
+ tool_use_id: block.toolUseId,
111
+ content: block.content,
112
+ is_error: block.isError,
113
+ });
114
+ }
115
+ }
116
+ // Breakpoint after a tool-result source message lands on the last block
117
+ // appended for it. Caching up to a mid-message block is valid (it just
118
+ // defines the prefix boundary), so merging doesn't break this.
119
+ if (wantCache) markCache(pendingUserBlocks[pendingUserBlocks.length - 1]);
120
+ }
121
+ });
122
+ flushUser();
123
+ return { system, messages: out };
124
+ }
125
+
126
+ function toAnthropicBlock(block: ContentBlock): AnthropicContentBlock {
127
+ switch (block.type) {
128
+ case 'text':
129
+ return { type: 'text', text: block.text };
130
+ case 'tool_use':
131
+ return { type: 'tool_use', id: block.id, name: block.name, input: block.input };
132
+ case 'tool_result':
133
+ return {
134
+ type: 'tool_result',
135
+ tool_use_id: block.toolUseId,
136
+ content: block.content,
137
+ is_error: block.isError,
138
+ };
139
+ case 'image':
140
+ return {
141
+ type: 'image',
142
+ source: { type: 'base64', media_type: block.mediaType, data: block.data },
143
+ };
144
+ case 'document':
145
+ // Native document support (PDF). Claude reads text + figures/layout.
146
+ return {
147
+ type: 'document',
148
+ source: { type: 'base64', media_type: block.mediaType, data: block.data },
149
+ ...(block.name ? { title: block.name } : {}),
150
+ };
151
+ case 'audio':
152
+ // Anthropic's Messages API does not accept native audio yet. Channels
153
+ // are supposed to transcribe up-front when the active model lacks
154
+ // `supportsAudio`; if an audio block reaches the translator anyway
155
+ // (e.g. a resumed session originally captured on a different
156
+ // provider), degrade to a text placeholder rather than throwing.
157
+ return {
158
+ type: 'text',
159
+ text: `[audio attachment dropped: ${block.mediaType} not supported by this model]`,
160
+ };
161
+ case 'reasoning':
162
+ // Replayed verbatim so Anthropic accepts an interleaved-thinking tool-use
163
+ // continuation. `redacted` → the opaque encrypted blob; otherwise the
164
+ // signed thinking block (projection only forwards reasoning that carries a
165
+ // signature or encrypted blob, so the text fallback below is unreachable).
166
+ if (block.redacted && block.encrypted) {
167
+ return { type: 'redacted_thinking', data: block.encrypted };
168
+ }
169
+ if (block.signature) {
170
+ return { type: 'thinking', thinking: block.text, signature: block.signature };
171
+ }
172
+ return { type: 'text', text: block.text };
173
+ }
174
+ }
175
+
176
+ export interface ToAnthropicToolsOptions {
177
+ /** Place a cache breakpoint on the last tool, caching the whole tools array. */
178
+ readonly cacheLast?: boolean;
179
+ }
180
+
181
+ export function toAnthropicTools(
182
+ tools: ReadonlyArray<ToolDef>,
183
+ opts: ToAnthropicToolsOptions = {},
184
+ ): AnthropicToolDef[] {
185
+ const out = tools.map((t) => ({
186
+ name: t.name,
187
+ description: t.description,
188
+ input_schema: t.inputJsonSchema ?? zodToJsonSchema(t.inputSchema),
189
+ })) as AnthropicToolDef[];
190
+ if (opts.cacheLast && out.length > 0) {
191
+ out[out.length - 1]!.cache_control = { type: 'ephemeral' };
192
+ }
193
+ return out;
194
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { validateKey } from './validate.js';
3
+
4
+ describe('anthropic validateKey', () => {
5
+ it('rejects empty/short keys without any network call', async () => {
6
+ const make = vi.fn();
7
+ const res = await validateKey('', { client: make as never });
8
+ expect(res).toEqual({ ok: false, message: 'key looks too short' });
9
+ expect(make).not.toHaveBeenCalled();
10
+ });
11
+
12
+ it('success path issues a 1-token messages.create', async () => {
13
+ const create = vi.fn().mockResolvedValue({});
14
+ const make = vi.fn().mockReturnValue({ messages: { create } });
15
+ const res = await validateKey('sk-ant-very-long-test-key', { client: make });
16
+ expect(res).toEqual({ ok: true });
17
+ expect(make).toHaveBeenCalledWith('sk-ant-very-long-test-key');
18
+ expect(create).toHaveBeenCalledOnce();
19
+ const args = create.mock.calls[0]?.[0] as Record<string, unknown>;
20
+ expect(args.max_tokens).toBe(1);
21
+ });
22
+
23
+ it('SDK throw → ok:false with the underlying message', async () => {
24
+ const make = () => ({
25
+ messages: {
26
+ create: async () => {
27
+ throw new Error('401 invalid api key');
28
+ },
29
+ },
30
+ });
31
+ const res = await validateKey('sk-ant-bad-but-long-enough', { client: make });
32
+ expect(res.ok).toBe(false);
33
+ if (!res.ok) expect(res.message).toContain('401');
34
+ });
35
+
36
+ it('respects model override', async () => {
37
+ const create = vi.fn().mockResolvedValue({});
38
+ const make = () => ({ messages: { create } });
39
+ await validateKey('sk-ant-key-of-reasonable-length', { client: make, model: 'claude-opus-4-7' });
40
+ const args = create.mock.calls[0]?.[0] as Record<string, unknown>;
41
+ expect(args.model).toBe('claude-opus-4-7');
42
+ });
43
+ });
@@ -0,0 +1,39 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+
3
+ export type ValidationResult = { ok: true } | { ok: false; message: string };
4
+
5
+ export interface ValidateKeyDeps {
6
+ /** Inject the Anthropic SDK constructor for tests. */
7
+ readonly client?: (apiKey: string) => {
8
+ messages: { create: (args: Record<string, unknown>) => Promise<unknown> };
9
+ };
10
+ readonly model?: string;
11
+ }
12
+
13
+ /**
14
+ * "Is this key accepted by Anthropic?" Issues a 1-token completion — effectively
15
+ * free. Returns ok or a useful error message.
16
+ */
17
+ export async function validateKey(key: string, deps: ValidateKeyDeps = {}): Promise<ValidationResult> {
18
+ if (!key || key.trim().length < 8) {
19
+ return { ok: false, message: 'key looks too short' };
20
+ }
21
+ const make = deps.client ?? defaultMaker;
22
+ try {
23
+ const client = make(key);
24
+ await client.messages.create({
25
+ model: deps.model ?? 'claude-haiku-4-5-20251001',
26
+ max_tokens: 1,
27
+ messages: [{ role: 'user', content: 'ping' }],
28
+ });
29
+ return { ok: true };
30
+ } catch (err) {
31
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
32
+ }
33
+ }
34
+
35
+ function defaultMaker(apiKey: string): { messages: { create: (args: Record<string, unknown>) => Promise<unknown> } } {
36
+ return new Anthropic({ apiKey }) as unknown as {
37
+ messages: { create: (args: Record<string, unknown>) => Promise<unknown> };
38
+ };
39
+ }