Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ OpenWire normalises differences between providers so callers get a consistent Op

Image input additionally requires VS Code 1.125 or newer, whatever the provider.

**Content normalisation.** Message `content` can be a plain string, `null`, or an array of
parts. Text parts flatten to a plain string. `image_url` parts are carried through as binary
image data rather than dropped. Unsupported part types (`input_audio`, `file`) are rejected
with a 400 instead of being silently discarded.
**Content normalisation.** Message `content` can be a plain string or an array of parts;
assistant content may also be `null` when valid `tool_calls` are present. Text parts flatten
to a plain string. User `image_url` parts are carried through as binary image data rather than
dropped. Malformed messages, unknown roles, role-incompatible images, and unsupported content
parts are rejected with a 400 instead of being silently normalised or discarded.

**Tool call fallback.** A model can answer with a raw XML `<function_calls>` block instead of
a native tool call. On non-streaming requests OpenWire parses that block into standard
Expand All @@ -83,7 +84,7 @@ OpenWire never silently ignores a parameter. Every field lands in one of four bu
|--------|-----------|--------|
| **Honoured** | Applied and enforced by OpenWire | `model`, `messages`, `stream`, `stream_options`, `tools`, `tool_choice`, `response_format`, `max_completion_tokens`, `n` |
| **Forwarded** | Passed through `modelOptions`; support and exact semantics depend on the selected VS Code model provider | `temperature`, `top_p`, `max_tokens`, `stop`, `seed`, `presence_penalty`, `frequency_penalty` |
| **Rejected** | `400`, because honouring them partially would be misleading | `n` greater than 1, out-of-range sampling values, unsupported `response_format.type`, unsupported or malformed JSON Schema constraints, invalid `tools`/`tool_choice` combinations, remote image URLs |
| **Rejected** | `400`, because honouring them partially would be misleading | malformed messages or `response_format`, `n` greater than 1, out-of-range sampling values, unsupported or malformed JSON Schema constraints, invalid `tools`/`tool_choice` combinations, invalid or remote image inputs |
| **Reported** | Accepted, but listed under `x_openwire.unsupported_params` in the response | `logprobs`, `top_logprobs`, `logit_bias`, `user`, `parallel_tool_calls`, `store`, `metadata`, `service_tier`, and other OpenAI fields with no VS Code equivalent |

Set `openWire.server.strictParams` to `true` to turn the **Reported** bucket into `400`s as well.
Expand Down Expand Up @@ -169,6 +170,8 @@ Vision requests use the standard OpenAI content-part shape:
fetch a URL on your behalf, because that would let any caller drive requests from your
machine into your own network.
- **Supported types.** `image/png`, `image/jpeg`, `image/gif`, `image/webp`.
- **Validated bytes.** Base64 must be canonical and non-empty, the file signature must match
the declared MIME type, and each decoded image is limited to 8 MiB.
- **Requires VS Code 1.125 or newer**, where `LanguageModelDataPart` reached the stable API.
On older builds an image request returns `501` rather than silently dropping the image.
Check `image_input.supported` in `GET /v1/capabilities`.
Expand Down Expand Up @@ -215,7 +218,7 @@ Replace `<model-id>` with any id from `GET /v1/models`. OpenWire matches on mode
| `GET` | `/health` | Health check |
| `GET` | `/v1/models` | List available models |
| `GET` | `/v1/models/:id` | Get specific model |
| `GET` | `/v1/capabilities` | What this build honours, rejects and ignores |
| `GET` | `/v1/capabilities` | What this build enforces, forwards, rejects, and reports |
| `POST` | `/v1/chat/completions` | Chat completion (streaming + non-streaming) |
| `POST` | `/v1/completions` | Legacy completions, returned in `text_completion` shape |

Expand All @@ -236,8 +239,8 @@ All settings live under `openWire.server.*` in VS Code:
| `rateLimitPerMinute` | `60` | Rate limit |
| `requestTimeoutSeconds` | `300` | Request timeout |
| `strictParams` | `false` | Reject parameters OpenWire cannot honour instead of reporting them |
| `jsonModeMaxRetries` | `1` | Repair attempts when `response_format` requires JSON |
| `maxRequestBodyMb` | `10` | Maximum request body size, raised from 1 MB for image input |
| `jsonModeMaxRetries` | `1` | Repair attempts when `response_format` requires JSON; integer from 0 to 3 |
| `maxRequestBodyMb` | `10` | Maximum request body size in MiB; integer from 1 to 100 |
| `enableLogging` | `false` | Verbose logging |

## Commands
Expand Down
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,18 @@
"description": "Reject requests containing parameters OpenWire cannot honour, instead of reporting them in the response"
},
"openWire.server.jsonModeMaxRetries": {
"type": "number",
"type": "integer",
"default": 1,
"minimum": 0,
"maximum": 3,
"description": "How many repair attempts to make when response_format requires JSON and the model returns something invalid"
},
"openWire.server.maxRequestBodyMb": {
"type": "number",
"type": "integer",
"default": 10,
"description": "Maximum request body size in megabytes. Image inputs need considerably more than plain text."
"minimum": 1,
"maximum": 100,
"description": "Maximum request body size in mebibytes (MiB). Image inputs need considerably more than plain text."
}
}
}
Expand Down
117 changes: 110 additions & 7 deletions src/routes/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ describe('normalizeContent', () => {
expect(normalizeContent(undefined)).toBe('');
});

it('stringifies other types', () => {
expect(normalizeContent(42)).toBe('42');
it('rejects malformed scalar content', () => {
expect(() => normalizeContent(42)).toThrow(/content must be/);
});
});

Expand Down Expand Up @@ -78,12 +78,21 @@ describe('image content parts', () => {
.toThrow(/not supported/);
});

it('rejects unknown and malformed content parts', () => {
expect(() => normalizeContentParts([{ type: 'video', url: 'x' }]))
.toThrow(/Content part type "video" is not supported/);
expect(() => normalizeContentParts([{ type: 'text', text: 42 }]))
.toThrow(/text part must contain a string/);
expect(() => normalizeContentParts([{}]))
.toThrow(/content part must specify a type/);
});

it('detects image parts across messages', () => {
const msgs = normalizeMessages([
{ role: 'user', content: 'hi' },
{
role: 'user',
content: [{ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${PNG_1PX}` } }],
content: [{ type: 'image_url', image_url: { url: `data:image/png;base64,${PNG_1PX}` } }],
},
]);
expect(hasImageParts(msgs)).toBe(true);
Expand All @@ -100,10 +109,34 @@ describe('parseDataUri', () => {
});

it('parses mime type and payload', () => {
const parsed = parseDataUri(`data:image/jpeg;base64,${PNG_1PX}`);
expect(parsed?.mimeType).toBe('image/jpeg');
const parsed = parseDataUri(`data:image/png;base64,${PNG_1PX}`);
expect(parsed?.mimeType).toBe('image/png');
expect(parsed?.base64).toBe(PNG_1PX);
});

it('rejects non-canonical or empty base64 payloads', () => {
expect(() => parseDataUri('data:image/png;base64,A=')).toThrow(/valid base64/);
expect(() => parseDataUri('data:image/png;base64,AAAA=')).toThrow(/valid base64/);
expect(() => parseDataUri(`data:image/png;base64,${PNG_1PX.slice(0, 8)}\n${PNG_1PX.slice(8)}`))
.toThrow(/whitespace/);
});

it('rejects a data URI without a declared image MIME type', () => {
expect(() => parseDataUri(`data:;base64,${PNG_1PX}`))
.toThrow(/must declare an image MIME type/);
});

it('rejects bytes whose signature does not match the declared MIME type', () => {
expect(() => parseDataUri(`data:image/jpeg;base64,${PNG_1PX}`))
.toThrow(/does not match declared image type/);
});

it('rejects decoded images larger than 8 MiB', () => {
const bytes = Buffer.alloc(8 * 1024 * 1024 + 1);
bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect(() => parseDataUri(`data:image/png;base64,${bytes.toString('base64')}`))
.toThrow(/exceeds 8 MiB/);
});
});

describe('normalizeMessages', () => {
Expand All @@ -119,8 +152,78 @@ describe('normalizeMessages', () => {
expect(msgs[0].tool_call_id).toBe('call_1');
});

it('returns an empty array for non-array input', () => {
expect(normalizeMessages(undefined)).toEqual([]);
it('rejects missing, non-array, and empty message lists', () => {
expect(() => normalizeMessages(undefined)).toThrow(/messages must be an array/);
expect(() => normalizeMessages({})).toThrow(/messages must be an array/);
expect(() => normalizeMessages([])).toThrow(/at least one message/);
});

it('rejects malformed entries and unknown roles', () => {
expect(() => normalizeMessages([null])).toThrow(/message at index 0 must be an object/);
expect(() => normalizeMessages([{ role: 'admin', content: 'x' }]))
.toThrow(/unsupported role/);
expect(() => normalizeMessages([{ role: 'user' }])).toThrow(/content is required/);
});

it('requires tool messages to identify their tool call', () => {
expect(() => normalizeMessages([{ role: 'tool', content: 'result' }]))
.toThrow(/tool_call_id/);
});

it('validates assistant tool call entries', () => {
for (const toolCall of [
null,
{},
{ id: 'call_1', type: 'other', function: { name: 'lookup', arguments: '{}' } },
{ id: '', type: 'function', function: { name: 'lookup', arguments: '{}' } },
{ id: 'call_1', type: 'function', function: { name: '', arguments: '{}' } },
{ id: 'call_1', type: 'function', function: { name: 'lookup', arguments: '{bad' } },
]) {
expect(() => normalizeMessages([{ role: 'assistant', content: null, tool_calls: [toolCall] }]))
.toThrow(/tool_calls/);
}
});

it('accepts a valid assistant tool call without text content', () => {
const messages = normalizeMessages([{
role: 'assistant',
content: null,
tool_calls: [{
id: 'call_1',
type: 'function',
function: { name: 'lookup', arguments: '{"q":"x"}' },
}],
}]);
expect(messages[0].tool_calls).toHaveLength(1);
});

it('rejects tool_calls on non-assistant messages', () => {
expect(() => normalizeMessages([{
role: 'user', content: 'x',
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'x', arguments: '{}' } }],
}])).toThrow(/tool_calls are only valid/);
});

it('enforces role-specific content requirements', () => {
for (const role of ['system', 'user', 'tool']) {
const message = role === 'tool'
? { role, content: null, tool_call_id: 'call_1' }
: { role, content: null };
expect(() => normalizeMessages([message])).toThrow(/requires non-empty content/);
}
expect(() => normalizeMessages([{ role: 'assistant', content: null }]))
.toThrow(/requires content or tool_calls/);
});

it('rejects image parts outside user messages', () => {
for (const role of ['system', 'assistant', 'tool']) {
const message: Record<string, unknown> = {
role,
content: [{ type: 'image_url', image_url: { url: `data:image/png;base64,${PNG_1PX}` } }],
};
if (role === 'tool') message.tool_call_id = 'call_1';
expect(() => normalizeMessages([message])).toThrow(/image parts are only supported in user messages/);
}
});
});

Expand Down
Loading
Loading