@moxxy/plugin-provider-openai 0.1.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/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/compat.d.ts +72 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +53 -0
- package/dist/compat.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +44 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +261 -0
- package/dist/provider.js.map +1 -0
- package/dist/translate.d.ts +41 -0
- package/dist/translate.d.ts.map +1 -0
- package/dist/translate.js +92 -0
- package/dist/translate.js.map +1 -0
- package/dist/validate.d.ts +28 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +24 -0
- package/dist/validate.js.map +1 -0
- package/package.json +64 -0
- package/src/compat.test.ts +127 -0
- package/src/compat.ts +105 -0
- package/src/index.ts +37 -0
- package/src/provider.test.ts +350 -0
- package/src/provider.ts +339 -0
- package/src/translate.test.ts +137 -0
- package/src/translate.ts +120 -0
- package/src/validate.test.ts +32 -0
- package/src/validate.ts +42 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { defineTool } from '@moxxy/sdk';
|
|
4
|
+
import { toOpenAIMessages, toOpenAITools } from './translate.js';
|
|
5
|
+
|
|
6
|
+
describe('toOpenAIMessages', () => {
|
|
7
|
+
it('flattens text into role+content', () => {
|
|
8
|
+
const out = toOpenAIMessages([
|
|
9
|
+
{ role: 'system', content: [{ type: 'text', text: 'be terse' }] },
|
|
10
|
+
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
|
11
|
+
]);
|
|
12
|
+
expect(out).toEqual([
|
|
13
|
+
{ role: 'system', content: 'be terse' },
|
|
14
|
+
{ role: 'user', content: 'hi' },
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('translates assistant tool_use into tool_calls', () => {
|
|
19
|
+
const out = toOpenAIMessages([
|
|
20
|
+
{
|
|
21
|
+
role: 'assistant',
|
|
22
|
+
content: [{ type: 'tool_use', id: 'c1', name: 'Read', input: { path: '/a' } }],
|
|
23
|
+
},
|
|
24
|
+
]);
|
|
25
|
+
expect(out[0].role).toBe('assistant');
|
|
26
|
+
expect(out[0].content).toBeNull();
|
|
27
|
+
expect(out[0].tool_calls).toEqual([
|
|
28
|
+
{ id: 'c1', type: 'function', function: { name: 'Read', arguments: '{"path":"/a"}' } },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('translates tool_result into role: tool messages', () => {
|
|
33
|
+
const out = toOpenAIMessages([
|
|
34
|
+
{
|
|
35
|
+
role: 'tool_result',
|
|
36
|
+
content: [{ type: 'tool_result', toolUseId: 'c1', content: 'ok', isError: false }],
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
expect(out).toEqual([{ role: 'tool', tool_call_id: 'c1', content: 'ok' }]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('emits a content parts array when a user message has an image', () => {
|
|
43
|
+
const out = toOpenAIMessages([
|
|
44
|
+
{
|
|
45
|
+
role: 'user',
|
|
46
|
+
content: [
|
|
47
|
+
{ type: 'text', text: 'what is this?' },
|
|
48
|
+
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
expect(out).toEqual([
|
|
53
|
+
{
|
|
54
|
+
role: 'user',
|
|
55
|
+
content: [
|
|
56
|
+
{ type: 'text', text: 'what is this?' },
|
|
57
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('emits a file content part when a user message has a document', () => {
|
|
64
|
+
const out = toOpenAIMessages([
|
|
65
|
+
{
|
|
66
|
+
role: 'user',
|
|
67
|
+
content: [
|
|
68
|
+
{ type: 'text', text: 'summarize this' },
|
|
69
|
+
{ type: 'document', mediaType: 'application/pdf', data: 'JVBERi0=', name: 'report.pdf' },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
expect(out).toEqual([
|
|
74
|
+
{
|
|
75
|
+
role: 'user',
|
|
76
|
+
content: [
|
|
77
|
+
{ type: 'text', text: 'summarize this' },
|
|
78
|
+
{
|
|
79
|
+
type: 'file',
|
|
80
|
+
file: { filename: 'report.pdf', file_data: 'data:application/pdf;base64,JVBERi0=' },
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('keeps user content as plain text when no images are present', () => {
|
|
88
|
+
const out = toOpenAIMessages([
|
|
89
|
+
{ role: 'user', content: [{ type: 'text', text: 'hello' }, { type: 'text', text: 'world' }] },
|
|
90
|
+
]);
|
|
91
|
+
expect(out[0].content).toBe('hello\nworld');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('handles assistant with both text and tool_calls', () => {
|
|
95
|
+
const out = toOpenAIMessages([
|
|
96
|
+
{
|
|
97
|
+
role: 'assistant',
|
|
98
|
+
content: [
|
|
99
|
+
{ type: 'text', text: 'let me check' },
|
|
100
|
+
{ type: 'tool_use', id: 'c1', name: 'Read', input: {} },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
expect(out[0].content).toBe('let me check');
|
|
105
|
+
expect(out[0].tool_calls).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('toOpenAITools', () => {
|
|
110
|
+
it('wraps each tool in { type: function, function: { ... } }', () => {
|
|
111
|
+
const tool = defineTool({
|
|
112
|
+
name: 'Read',
|
|
113
|
+
description: 'read file',
|
|
114
|
+
inputSchema: z.object({ path: z.string() }),
|
|
115
|
+
handler: () => null,
|
|
116
|
+
});
|
|
117
|
+
const out = toOpenAITools([tool]);
|
|
118
|
+
expect(out[0].type).toBe('function');
|
|
119
|
+
expect(out[0].function.name).toBe('Read');
|
|
120
|
+
expect(out[0].function.description).toBe('read file');
|
|
121
|
+
expect((out[0].function.parameters as { type: string }).type).toBe('object');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('prefers inputJsonSchema when present', () => {
|
|
125
|
+
const tool = defineTool({
|
|
126
|
+
name: 'X',
|
|
127
|
+
description: 'x',
|
|
128
|
+
inputSchema: z.any(),
|
|
129
|
+
inputJsonSchema: { type: 'object', properties: { custom: { type: 'integer' } } },
|
|
130
|
+
handler: () => null,
|
|
131
|
+
});
|
|
132
|
+
const out = toOpenAITools([tool]);
|
|
133
|
+
expect((out[0].function.parameters as { properties: { custom: unknown } }).properties.custom).toEqual({
|
|
134
|
+
type: 'integer',
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
package/src/translate.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ProviderMessage, ToolDef } from '@moxxy/sdk';
|
|
2
|
+
import { zodToJsonSchema } from '@moxxy/sdk';
|
|
3
|
+
|
|
4
|
+
export type OpenAIUserContentPart =
|
|
5
|
+
| { type: 'text'; text: string }
|
|
6
|
+
| { type: 'image_url'; image_url: { url: string } }
|
|
7
|
+
| { type: 'file'; file: { filename?: string; file_data: string } };
|
|
8
|
+
|
|
9
|
+
export interface OpenAIChatMessage {
|
|
10
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
11
|
+
content?: string | ReadonlyArray<OpenAIUserContentPart> | null;
|
|
12
|
+
tool_calls?: Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
type: 'function';
|
|
15
|
+
function: { name: string; arguments: string };
|
|
16
|
+
}>;
|
|
17
|
+
tool_call_id?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OpenAIToolDef {
|
|
22
|
+
type: 'function';
|
|
23
|
+
function: {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
parameters: unknown;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function toOpenAIMessages(messages: ReadonlyArray<ProviderMessage>): OpenAIChatMessage[] {
|
|
31
|
+
const out: OpenAIChatMessage[] = [];
|
|
32
|
+
for (const msg of messages) {
|
|
33
|
+
if (msg.role === 'system') {
|
|
34
|
+
const text = msg.content.find((c): c is { type: 'text'; text: string } => c.type === 'text')?.text ?? '';
|
|
35
|
+
if (text) out.push({ role: 'system', content: text });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (msg.role === 'user') {
|
|
39
|
+
const hasRichPart = msg.content.some((c) => c.type === 'image' || c.type === 'document');
|
|
40
|
+
if (hasRichPart) {
|
|
41
|
+
// Vision/document user message: emit content as a parts array so
|
|
42
|
+
// base64 images and documents (PDFs) ride alongside text. Non-vision
|
|
43
|
+
// / non-document OpenAI models will 400 on this shape — callers gate
|
|
44
|
+
// by `supportsImages` / `supportsDocuments` on the model descriptor
|
|
45
|
+
// before attaching.
|
|
46
|
+
const parts: OpenAIUserContentPart[] = [];
|
|
47
|
+
for (const c of msg.content) {
|
|
48
|
+
if (c.type === 'text') {
|
|
49
|
+
parts.push({ type: 'text', text: c.text });
|
|
50
|
+
} else if (c.type === 'image') {
|
|
51
|
+
parts.push({
|
|
52
|
+
type: 'image_url',
|
|
53
|
+
image_url: { url: `data:${c.mediaType};base64,${c.data}` },
|
|
54
|
+
});
|
|
55
|
+
} else if (c.type === 'document') {
|
|
56
|
+
parts.push({
|
|
57
|
+
type: 'file',
|
|
58
|
+
file: {
|
|
59
|
+
...(c.name ? { filename: c.name } : {}),
|
|
60
|
+
file_data: `data:${c.mediaType};base64,${c.data}`,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
out.push({ role: 'user', content: parts });
|
|
66
|
+
} else {
|
|
67
|
+
const text = msg.content
|
|
68
|
+
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
|
|
69
|
+
.map((c) => c.text)
|
|
70
|
+
.join('\n');
|
|
71
|
+
out.push({ role: 'user', content: text });
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (msg.role === 'assistant') {
|
|
76
|
+
const text = msg.content
|
|
77
|
+
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
|
|
78
|
+
.map((c) => c.text)
|
|
79
|
+
.join('');
|
|
80
|
+
const toolUses = msg.content.filter(
|
|
81
|
+
(c): c is { type: 'tool_use'; id: string; name: string; input: unknown } =>
|
|
82
|
+
c.type === 'tool_use',
|
|
83
|
+
);
|
|
84
|
+
const message: OpenAIChatMessage = { role: 'assistant', content: text || null };
|
|
85
|
+
if (toolUses.length > 0) {
|
|
86
|
+
message.tool_calls = toolUses.map((u) => ({
|
|
87
|
+
id: u.id,
|
|
88
|
+
type: 'function' as const,
|
|
89
|
+
function: { name: u.name, arguments: JSON.stringify(u.input ?? {}) },
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
out.push(message);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (msg.role === 'tool_result') {
|
|
96
|
+
for (const block of msg.content) {
|
|
97
|
+
if (block.type === 'tool_result') {
|
|
98
|
+
out.push({
|
|
99
|
+
role: 'tool',
|
|
100
|
+
tool_call_id: block.toolUseId,
|
|
101
|
+
content: block.content,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function toOpenAITools(tools: ReadonlyArray<ToolDef>): OpenAIToolDef[] {
|
|
111
|
+
return tools.map((t) => ({
|
|
112
|
+
type: 'function' as const,
|
|
113
|
+
function: {
|
|
114
|
+
name: t.name,
|
|
115
|
+
description: t.description,
|
|
116
|
+
parameters: (t.inputJsonSchema ?? zodToJsonSchema(t.inputSchema)) as unknown,
|
|
117
|
+
},
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { validateKey } from './validate.js';
|
|
3
|
+
|
|
4
|
+
describe('openai 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 lists models', async () => {
|
|
13
|
+
const list = vi.fn().mockResolvedValue({ data: [] });
|
|
14
|
+
const make = vi.fn().mockReturnValue({ models: { list } });
|
|
15
|
+
const res = await validateKey('sk-very-long-test-key', { client: make });
|
|
16
|
+
expect(res).toEqual({ ok: true });
|
|
17
|
+
expect(list).toHaveBeenCalledOnce();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('SDK throw → ok:false with the underlying message', async () => {
|
|
21
|
+
const make = () => ({
|
|
22
|
+
models: {
|
|
23
|
+
list: async () => {
|
|
24
|
+
throw new Error('Incorrect API key provided');
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const res = await validateKey('sk-bad-but-long-enough', { client: make });
|
|
29
|
+
expect(res.ok).toBe(false);
|
|
30
|
+
if (!res.ok) expect(res.message).toContain('Incorrect API key');
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
|
|
3
|
+
export type ValidationResult = { ok: true } | { ok: false; message: string };
|
|
4
|
+
|
|
5
|
+
export interface ValidateKeyDeps {
|
|
6
|
+
/** Inject the OpenAI SDK constructor for tests. */
|
|
7
|
+
readonly client?: (apiKey: string) => {
|
|
8
|
+
models: { list: () => Promise<unknown> };
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Endpoint to probe. Omit for OpenAI itself; pass a vendor base URL to
|
|
12
|
+
* validate an OpenAI-compatible provider (groq, deepseek, openrouter, …)
|
|
13
|
+
* against its own `/v1/models`. Lets `@moxxy/plugin-provider-admin` reuse
|
|
14
|
+
* this validator instead of duplicating the OpenAI-compatible probe.
|
|
15
|
+
*/
|
|
16
|
+
readonly baseURL?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* "Is this key accepted by the endpoint?" Lists models — free, no inference
|
|
21
|
+
* cost. Validates OpenAI by default, or an OpenAI-compatible vendor when a
|
|
22
|
+
* `baseURL` is supplied.
|
|
23
|
+
*/
|
|
24
|
+
export async function validateKey(key: string, deps: ValidateKeyDeps = {}): Promise<ValidationResult> {
|
|
25
|
+
if (!key || key.trim().length < 8) {
|
|
26
|
+
return { ok: false, message: 'key looks too short' };
|
|
27
|
+
}
|
|
28
|
+
const make = deps.client ?? ((k: string) => defaultMaker(k, deps.baseURL));
|
|
29
|
+
try {
|
|
30
|
+
const client = make(key);
|
|
31
|
+
await client.models.list();
|
|
32
|
+
return { ok: true };
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function defaultMaker(apiKey: string, baseURL?: string): { models: { list: () => Promise<unknown> } } {
|
|
39
|
+
return new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }) as unknown as {
|
|
40
|
+
models: { list: () => Promise<unknown> };
|
|
41
|
+
};
|
|
42
|
+
}
|