Expose VS Code language models as an OpenAI-compatible REST API on localhost.
One extension. Every model VS Code can see. Standard API. Built for agents.
- OpenAI-compatible.
/v1/chat/completionsand/v1/models, with SSE streaming. - Auto-discovery. Every language model registered in VS Code shows up. No configuration.
- Enforced JSON mode.
response_formatis honoured server-side: strict instruction, JSON recovery from prose, schema validation, and a bounded repair retry. - Sampling controls.
temperature,top_p,max_tokens,stop,seedand penalties are forwarded to the model. - Image input. OpenAI
image_urlparts reach vision-capable models (VS Code 1.125+). - No silent no-ops. Anything OpenWire cannot honour is rejected or reported back in the response.
GET /v1/capabilitiesstates what this build supports. - Tool forwarding. Send OpenAI-format tools, get
tool_callsback, with fulltool_choicesupport. - XML tool call fallback. Non-streaming responses containing a
<function_calls>block are converted totool_calls. - Rate limiting. Configurable per-minute request cap.
- API key auth. Bearer token authentication enabled by default.
- Tight CORS defaults. Browser requests are limited to loopback origins.
- Zero dependencies. Node's built-in HTTP server, no Express.
Any model registered with VS Code's Language Model API is exposed automatically. In practice that means:
- Claude. Whatever Anthropic model family/tier is currently registered by an installed extension (e.g. Opus, Sonnet, Haiku)
- GPT. Whatever OpenAI model family is currently registered by an installed extension (e.g. GPT and reasoning-tier variants)
- Gemini. Whatever Google model family is currently registered by an installed extension (e.g. Pro, Flash)
- Ollama. Only when a separate extension registers your local models with VS Code. OpenWire never talks to Ollama directly.
- Anything else registered with the VS Code Language Model API
Call GET /v1/models to see what your setup actually exposes.
OpenWire normalises differences between providers so callers get a consistent OpenAI-format response:
| Provider | Content format | Tool calling | Image input | Status |
|---|---|---|---|---|
| Claude (Anthropic) | Array of {"type":"text","text":"..."} parts |
Native via VS Code API, plus an XML <function_calls> fallback on non-streaming responses |
Vision models | Supported |
| GPT (OpenAI) | Plain string | Native tool_calls via VS Code API |
Vision models | Supported |
| Gemini (Google) | Plain string, or text parts tagged type: "text" |
Native via VS Code API | Vision models | Supported |
| Ollama (local) | Plain string | Depends on the model | Depends on the model | Only via a VS Code LM provider extension |
Image input additionally requires VS Code 1.125 or newer, whatever the provider.
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
tool_calls and strips it from the message content. Streaming requests rely on native
tool-call parts, so an XML block can still reach the client as ordinary text.
OpenWire never silently ignores a parameter. Every field lands in one of four buckets, and
GET /v1/capabilities reports the split for the running build.
| Bucket | Behaviour | Fields |
|---|---|---|
| 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 |
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 400s as well.
That gives agents a hard guarantee that unsupported fields are rejected. It cannot guarantee
that a model provider applies fields in the Forwarded bucket; check the provider's own
capabilities when those controls are required.
Invalid values are rejected rather than clamped. temperature: 9 returns a 400; it does
not quietly become 2.
prompt is honoured only by /v1/completions. Sending it to /v1/chat/completions has no
effect, so it is reported there like any other unknown field.
Streaming requests carry the same report in a metadata-only frame (choices: []) emitted
before the first content delta, so stream: true is not a way to lose it.
The VS Code Language Model API has no provider-level JSON mode, so OpenWire enforces
response_format itself:
- A strict instruction is appended to the conversation.
- The reply is parsed. JSON is recovered even when wrapped in prose or ```json fences.
- For
json_schema, the value is validated against the schema. - On failure, OpenWire retries once with a harsher instruction that quotes the bad output.
- If it still cannot produce valid JSON it returns 502. It never returns prose that claims to be JSON.
curl http://localhost:3030/v1/chat/completions \
-H "Authorization: Bearer $OPENWIRE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "<model-id>",
"messages": [{"role": "user", "content": "Rate this repo out of 10"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "rating",
"schema": {
"type": "object",
"required": ["score"],
"properties": {"score": {"type": "integer", "minimum": 0, "maximum": 10}}
}
}
}
}'json_object guarantees a JSON object. A bare scalar or array is treated as a failure
and sent back through the repair retry, so JSON.parse(content).field is always safe.
Supported schema keywords. type, enum, const, required, properties,
additionalProperties (boolean or sub-schema), items (single schema), minimum,
maximum, minLength, maxLength, minItems, maxItems. Annotations such as title
and description are ignored.
Anything else returns a 400 rather than being accepted and left unenforced — including
$ref, allOf, anyOf, oneOf, patternProperties, and the tuple form of items.
Streaming. Validity cannot be judged mid-stream, so a JSON-mode request with
stream: true buffers the reply, enforces the contract, then emits it as a single content
delta followed by [DONE]. The SSE contract is preserved; the token-by-token behaviour is not.
Vision requests use the standard OpenAI content-part shape:
- Base64
data:URIs only. Remotehttp(s)image URLs return a400. OpenWire will not 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
LanguageModelDataPartreached the stable API. On older builds an image request returns501rather than silently dropping the image. Checkimage_input.supportedinGET /v1/capabilities. - Image payloads are large, so the request body limit defaults to 10 MB
(
openWire.server.maxRequestBodyMb).
Install from the VS Code Marketplace, or load the .vsix yourself. The server starts on http://127.0.0.1:3030 as soon as VS Code loads the extension.
export OPENWIRE_API_KEY="change-me-openwire-key"
# List available models
curl http://localhost:3030/v1/models \
-H "Authorization: Bearer $OPENWIRE_API_KEY"
# Chat completion
curl http://localhost:3030/v1/chat/completions \
-H "Authorization: Bearer $OPENWIRE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "<model-id>",
"messages": [{"role": "user", "content": "Hello"}]
}'
# Streaming
curl http://localhost:3030/v1/chat/completions \
-H "Authorization: Bearer $OPENWIRE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "<model-id>",
"messages": [{"role": "user", "content": "Explain zero-knowledge proofs"}],
"stream": true
}'Replace <model-id> with any id from GET /v1/models. OpenWire matches on model id or family and has no built-in aliases, so an unrecognised name returns a 404 listing what is available.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
GET |
/v1/models |
List available models |
GET |
/v1/models/:id |
Get specific model |
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 |
All settings live under openWire.server.* in VS Code:
| Setting | Default | Description |
|---|---|---|
autoStart |
true |
Start server when VS Code launches |
host |
127.0.0.1 |
Bind address |
port |
3030 |
Port number |
apiKey |
"change-me-openwire-key" |
Bearer token required for authentication (change this locally) |
corsAllowedOrigins |
["http://localhost", "http://127.0.0.1", "http://[::1]"] |
Allowed browser origins for CORS |
defaultModel |
"" |
Fallback model when none specified |
defaultSystemPrompt |
"" |
Injected system prompt if none present |
maxConcurrentRequests |
4 |
Concurrent request limit |
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; integer from 0 to 3 |
maxRequestBodyMb |
10 |
Maximum request body size in MiB; integer from 1 to 100 |
enableLogging |
false |
Verbose logging |
- OpenWire: Start Server
- OpenWire: Stop Server
- OpenWire: Restart Server
- OpenWire: Toggle Server
OpenWire works as a model provider for OpenClaw agents. Register OpenWire as a custom provider called copilot-proxy in your ~/.openclaw/openclaw.json:
{
"models": {
"providers": {
"copilot-proxy": {
"baseUrl": "http://localhost:3030/v1",
"apiKey": "change-me-openwire-key",
"api": "openai-completions",
"models": [
{
"id": "<model-id>",
"name": "<Display Name>",
"contextWindow": 128000,
"maxTokens": 8192
}
// add any other models from /v1/models
]
}
}
},
"agents": {
"defaults": {
"model": {
"primary": "copilot-proxy/<model-id>"
}
}
},
"plugins": {
"entries": {
"copilot-proxy": { "enabled": true }
}
}
}OpenWire requires a Bearer token by default. Set a custom openWire.server.apiKey in VS Code and use the same value in OpenClaw.
src/
extension.ts activation, commands, status bar
models/
discovery.ts model discovery, caching, dedup
routes/
chat.ts vscode binding + orchestration
content.ts message/content normalisation, image parts
params.ts sampling params, capability classification
json-mode.ts response_format enforcement
schema.ts JSON Schema subset validator
tool-calls.ts tool mapping, tool_choice, XML fallback
legacy.ts text_completion shape for /v1/completions
errors.ts RequestError with an HTTP status
server/
config.ts settings loader
gateway.ts HTTP server, routing, middleware
ui/
sidebar.ts webview sidebar panel
test/
vscode-mock.ts in-memory vscode stand-in for tests
gateway.test.ts end-to-end HTTP tests
types/
vscode-lm.d.ts type augmentations
Everything under `routes/` except `chat.ts` is free of `vscode` imports, so it is unit
tested directly. `gateway.test.ts` runs the real server over HTTP against a mocked
`vscode` module, aliased in `vitest.config.ts`.
Clone the repo and install dependencies:
npm installStart esbuild in watch mode, then press F5 in VS Code to launch the Extension Development Host with OpenWire loaded:
npm run watch.vscode/launch.json has two configurations. "Run Extension" builds once and
launches. "Run Extension (Watch)" starts the watcher for you. Reload the
Development Host window to pick up a rebuild.
| Command | What it does |
|---|---|
npm run lint |
Type checks with tsc --noEmit |
npm run test |
Runs the Vitest suite |
npm run build |
Bundles src/ into dist/extension.js |
npm run package |
Builds a production bundle and writes a local .vsix |
npm run package fails when @types/vscode is newer than engines.vscode,
so raising the types version means raising the engine floor too. The reverse
is not checked. Raising engines.vscode on its own packages and publishes
without complaint, which leaves the types older than the VS Code version the
extension now claims to support, so bump @types/vscode to match by hand.
CI runs lint, build and test on Node 20 and 22, and packages the extension once
on Node 20. The .vsix from that run is uploaded as a build artifact, so you
can install a branch build before it ships. It runs on every pull request
whatever base branch it targets, so stacked branches are covered too.
To cut a release:
- Bump
versioninpackage.jsonand merge that tomain. - Tag the merge commit as
vX.Y.Z, matching the version you just set. - Push the tag.
The Release workflow checks the tag against the version in package.json and
stops if they disagree. It then runs lint, build and test, packages a single
.vsix, publishes that exact file to the VS Code Marketplace, and attaches it
to a GitHub release. Publishing needs a VSCE_PAT repository secret.
{ "model": "<model-id>", "messages": [{ "role": "user", "content": [ {"type": "text", "text": "What is in this screenshot?"}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KG..."}} ] }] }