Skip to content
| Marketplace
Sign in
Visual Studio Code>Machine Learning>Model GateNew to Visual Studio Code? Get it now.
Model Gate

Model Gate

jkudo

|
1 install
| (0) | Free
Lightweight LLM gateway for Claude Code, Codex and GitHub Copilot. Route them to local or cloud models through one HTTP endpoint, with usage visibility.
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

Model Gate

An LLM gateway inside VS Code: it lets Claude Code, Codex and GitHub Copilot use local models (Ollama, LM Studio, llama.cpp) or any cloud model.

Everything goes over HTTP so that usage can be made visible. All three clients pass through a single gateway, so tokens and cost compare directly under one set of definitions.

Claude Code ──> /v1/messages          (Anthropic format)  ┐
Codex       ──> /v1/responses         (Responses format)  ├─> translate ─> any provider
Copilot     ──> /api/chat, /v1/...    (Ollama/OpenAI)     ┘             └─> usage record

Install

For now the .vsix is distributed directly.

code --install-extension model-gate-0.1.0.vsix

From the GUI: the Extensions view, … in the top right, "Install from VSIX…". It needs VS Code 1.90 or later and Node.js 20 or later.

Using it

1. Start it (no configuration)

Enabling the extension starts the gateway automatically. At the same time it:

  • probes 127.0.0.1:11434 (Ollama), :1234 (LM Studio), :8080 (llama.cpp), :8000 (vLLM)
  • publishes whatever models it finds (fetching context length and tool support from the upstream)
  • generates an authentication token and stores it in SecretStorage

No configuration file is required at this point.

2. Connect the clients

Run Model Gate: Set Up Clients from the command palette. Each client gets its own token, so usage is reliably attributed per client.

Client What gets written
Claude Code the env block of settings.json (see below), or the environment variables to copy
Codex ~/.codex/config.toml (previewed as a diff before writing), optionally a per-workspace model in <workspace>/.codex/config.toml, plus the key in VS Code's terminals
GitHub Copilot instructions for adding it under Manage Models

The pre-write preview opens as a read-only virtual document. An unsaved Untitled tab would prompt to save on every close, and an accidental save would drop a file somewhere unrelated. The tab closes itself once you have answered (write / cancel).

Claude Code (the VS Code extension)

The Claude Code extension inside VS Code does not inherit terminal environment variables. export ANTHROPIC_BASE_URL=... will not connect, so the settings go into the env block of settings.json. There are three places to write it:

Choice File Purpose
This workspace only (recommended) .claude/settings.local.json Not under Git. Safe for a token
Shared with the workspace .claude/settings.json Committed to Git. A token would be shared, so this asks first
This machine ~/.claude/settings.json Applies to every project

What gets written:

{
  "env": {
    "ANTHROPIC_BASE_URL": "http://127.0.0.1:4000",
    "ANTHROPIC_AUTH_TOKEN": "mg-claude-code-...",
    "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1"
  },
  "availableModels": ["claude-fable-5", "claude-opus-5", "gpt-5.6-sol", "FW-Kimi-K3"],
  "enforceAvailableModels": true,
  "model": "claude-fable-5"
}

availableModels lists the published models (their ids) verbatim. Setting enforceAvailableModels means the model picker only offers models that really are callable through the gateway, so you cannot pick a name that fails. When no models are published, neither key is written, to avoid an empty picker.

Files carrying an authentication token are made readable by their owner only (600). A write-temp-and-rename scheme otherwise takes on the new file's mode, so the original mode is inherited and, for secrets, group and other read access is dropped (measured: a config.toml Codex had created with 600 had loosened to 644).

Existing settings such as permissions and hooks survive. Rather than loading and writing back, only our own keys are changed, as a diff — comments, ordering and indentation stay as they were (positions are located with jsonc-parser, the same library VS Code uses for settings.json, while the formatting is matched to the original file here). Writing and then removing restores the original text. The content is always previewed and confirmed before saving. If .claude/settings.local.json is not in .gitignore, adding it is offered.

The same menu has "Remove the Model Gate settings" to undo it (deleting the three env keys plus availableModels / enforceAvailableModels, leaving everything else). Only files Model Gate wrote are touched, and within them only the keys it wrote (model stays — it may be the user's own choice). A file counts as a target when ANTHROPIC_BASE_URL points at a loopback address or ANTHROPIC_AUTH_TOKEN starts with mg-. availableModels is also a genuine Claude Code setting, so it alone is never taken as evidence of "Model Gate settings" — that is what stopped a hand-written settings.json from being destroyed as collateral. The target files are listed for confirmation before anything is deleted. Settings left pointing at a stopped gateway make Claude Code unusable, so removal always exists. In particular, restoring the endpoint while leaving enforceAvailableModels behind would leave no selectable model, so both go together.

Codex

This is what gets written:

model = "<default model>"
model_provider = "modelgate"

[model_providers.modelgate]
name = "modelgate"
base_url = "http://127.0.0.1:4000/v1"
wire_api = "responses"                        # the only value Codex accepts
experimental_bearer_token = "mg-codex-..."    # the key goes here, inline
requires_openai_auth = false

model_context_window = 1000000                # so limits are handled correctly (see below)
  • wire_api can only be responses. The Codex configuration reference states "responses is the only supported value", and chat does not connect. That is why the gateway also accepts POST /v1/responses (see the protocol table below).
  • The key is written inline and env_key is deleted. env_key is the field for the name of an environment variable; putting the key itself there gives Missing environment variable: mg-.... Measured further: with both env_key and experimental_bearer_token present, env_key wins, and it fails on any machine where that variable is unset. So env_key is removed on write (MODELGATE_API_KEY is also set in VS Code's terminals, so an env_key style still works if you prefer it).
  • experimental_bearer_token is experimental, as the name says. Codex may rename it.
  • Model metadata for '...' not found comes from the published name. Codex looks up its built-in model list by name, so a name it does not know produces this (a display-only warning; nothing stops working). Measured: with the same Azure claude-opus-5 behind it, publishing it as gpt-5.6-sol made the warning disappear. If it bothers you, rename the model to something Codex knows — but then the name and the substance disagree, which is why the usage panel lets you see the upstream model that actually answered (requested, resolved and upstream names are recorded separately). model_context_window has nothing to do with the warning; it is written so context length is handled correctly.

Verified against real hardware (Codex CLI 0.147.0):

$ codex exec "run echo hello-from-codex and tell me the output"
  exec /bin/bash -c 'echo hello-from-codex' succeeded in 0ms: hello-from-codex
  codex The output is `hello-from-codex`.
  tokens used 27,731

The full round trip — tool call, execution, result returned, conversation continued — works.

Asking Codex "who are you?" answers "Codex CLI, provided by OpenAI…". That is Codex sending its own system prompt; it says so regardless of what it is connected to. Which model actually answered is visible in the usage panel (the upstream model column). For comparison, the same route and model asked without that system prompt:

$ curl .../v1/responses -d '{"model":"claude-opus-5","input":"which company built you, and which model are you?"}'
I am Claude, an AI built by Anthropic.
Pinning a model per workspace

Codex layers <workspace>/.codex/config.toml on top of the user-level file, so the default model can differ per project. The setup flow offers it as a second target, alongside "this machine".

Two measured limits shape what that file may contain.

  • model_provider and model_providers are refused there. Codex says so out loud: Ignored unsupported project-local config keys in <ws>/.codex/config.toml: model_provider, model_providers. If you want these settings to apply, manually set them in your user-level config.toml. A sensible refusal — otherwise a cloned repository could redirect Codex, and the token it carries, to an endpoint of its choosing. So the endpoint always stays in ~/.codex/config.toml, and choosing the workspace target writes there too (leaving the machine-wide model alone, since that is the user's own choice).
  • A project-local configuration is ignored until the directory is trusted. Codex asks once per folder ("Trusting the directory allows project-local config, hooks, and exec policies to load"). Before that it is silently skipped, so the notification after writing says as much.

What lands in the workspace is therefore just:

model = "claude-opus-5"
model_context_window = 1000000

No endpoint, no token — so unlike .claude/settings.local.json, this file can be committed and shared with the repository.

When a configuration already exists, you get the same two choices as with Claude Code.

Choice Effect
Update the settings bring the port, token and default model up to date (as a diff)
Remove the Model Gate settings drop the provider entry and put Codex back on its previous endpoint

Both are differential edits. Approval settings, other providers, MCP server definitions and comments are untouched (and the content is previewed before writing). Removal drops [model_providers.modelgate] and model_provider, and deletes model and model_context_window only when the model is one this gateway publishes (the user may have named a different model; conversely, leaving our model name while removing the endpoint would make Codex send OpenAI a name it does not know, and fail).

3. Look at the usage

Click $(server) Model Gate :4000 · 1.2M tok in the status bar and choose Open Usage. It shows tokens, cost, latency, TTFT and errors by client, by model and by provider, with CSV/JSON export.

Daily heatmap

At the top of the panel, daily token counts are laid out like GitHub's contribution graph, covering a full year (53 weeks x 7 weekdays). It is not affected by the period filter — it is a time axis itself, so shrinking it to one cell for "Today" would be pointless.

Intensity comes from quartiles of the non-zero days, not fixed thresholds. Someone doing 10k tokens a day and someone doing 10M a day use the same extension; with fixed cut-offs one of them gets an entirely dark grid and the other an entirely light one, and the shading carries no information.

The colours are a single-hue lightness ramp (inverted on dark backgrounds), and all four steps have verified contrast against the background. Hovering or focusing a cell shows the numbers, but nothing depends on colour and hover alone: the same numbers are readable as a table.

Per-model connection test

The model list in the settings panel has a Test button per row and a Test all button, because a reachable endpoint does not mean a given model is callable:

  • Azure answers 404 DeploymentNotFound for catalogue names that were never deployed
  • permissions, billing and region limits can differ per model
  • some models have no /chat/completions and are callable only through the Responses API

So instead of a list fetch or a HEAD, it sends one minimal max_tokens: 16 request through the same provider implementation the gateway uses in production. Dialect handling and automatic delegation to the Responses API take the same path, which preserves the guarantee that whatever passes here passes from a client. It consumes a few tokens.

Failures are turned into sentences with a cause (404 DeploymentNotFound becomes "check the deployment name", 429 becomes "rate limited (the connection itself works)"). Presenting a 429 as a plain failure sends people off to change configuration that was never wrong.

"Test all" runs sequentially. Firing them in parallel trips rate limits, and then a model that cannot be called is indistinguishable from one that was merely throttled. Models you have not tested show a grey "Untested" rather than an error colour.

State colours

Green for healthy, red for failing, everywhere (status bar, sidebar, settings panel).

Place Healthy Failing
Status bar green text red text (stopped, or recent requests failing in a row)
Sidebar endpoints and models green icon red error icon (no API key)
Settings panel green text red text

The status bar judges failure from the most recent results, not today's total error count. Staying red all day over one morning failure makes a working gateway look broken. The icon shape changes as well (● / error / circle-slash), so nothing depends on colour alone.

Configuration

The configuration file is ~/.modelgate/config.yaml (optional). It is designed to minimize how much you have to write.

Adding a cloud provider

providers:
  openrouter:
    key: secret:openrouter   # the value lives in SecretStorage (registered by command)

models:
  - openrouter/anthropic/claude-sonnet-4.5

default: openrouter/anthropic/claude-sonnet-4.5
  • Known providers need only a name for type and base_url to be resolved (ollama, lmstudio, llamacpp, vllm, openai, anthropic, openrouter, deepseek, groq, xai, together, mistral, gemini, bedrock, vertex). The exception is vertex, whose base URL contains the project ID, so it needs base_url.
  • The settings panel only offers the three big clouds (Azure OpenAI, AWS Bedrock, Google Vertex AI) plus "Other (specify a URL)". The other names above (including gemini) still work if written straight into this configuration file — they are merely hidden from the picker and can be restored at any time.
  • context_window, max_output_tokens and pricing never have to be written — they are fetched from the upstream or resolved from the built-in table.
  • API keys are never written into the YAML. Store them with Model Gate: Register API Key; the YAML keeps only a secret:<name> reference.
  • Setting server.host to anything other than 127.0.0.1, or server.auth to none, produces a warning. This gateway holds cloud API keys and calls upstreams on your behalf, so making it reachable lets everyone who can reach it spend against those keys (the setting itself is allowed).

Registering a model is just typing a name

The settings panel shows no model list. You type a name and register it.

That is because listed names and callable names do not match. What you can call on Azure OpenAI is the deployment name you chose in the portal, while /models returns that region's catalogue (measured: 437 catalogue entries against 11 callable deployments). Sending a catalogue name gives 404 DeploymentNotFound. Conversely, some tenants cannot read the deployment list at all for permission reasons, and deployments exist that the list omits (2 of them, measured).

So a list is either too long or too short, and it guarantees nothing about a name. Put one on screen and it reads as "listed means callable", which also talks people out of correct names that never appear. Fetching it needs extra permissions, too.

Whether a name really works is answered by the per-model connection test, not by a list — it makes a real round trip along the production path, so passing there means passing from a client.

One round trip at registration settles how the model is called, and the answer is stored. Even on upstreams that call themselves "OpenAI compatible", which surface holds the model and which spelling it accepts cannot be known without asking. Discovering that in production would waste a round trip per model and start over after every process restart.

models:
  - id: gemma4
    use: bedrock/google.gemma-4-31b
    route: responses                                                    # <- surface (automatic)
    route_base_url: https://bedrock-mantle.us-east-1.api.aws/openai/v1  # <- only when it differs
  - id: gpt
    use: azure/gpt-5.6-sol
    route: chat
    dialect:
      token_param: max_completion_tokens                                # <- spelling (automatic)

You never have to write these by hand (pressing Test again refreshes them). Measured (registration vs production):

Endpoint Model At registration In production
AWS Bedrock openai.gpt-oss-120b 1 round trip 1 round trip /v1/chat/completions
AWS Bedrock google.gemma-4-31b 2 (probing the surface) 1 round trip /openai/v1/responses
AWS Bedrock anthropic.claude-* 1 (known from the ID) 1 round trip /anthropic/v1/messages
Azure OpenAI gpt-5.6-sol 2 (learning the dialect) 1 round trip sending max_completion_tokens
Azure OpenAI claude-fable-5 2 (probing the surface) 1 round trip /openai/v1/responses
Azure OpenAI FW-Kimi-K3 1 round trip 1 round trip
Google Vertex AI google/gemini-3.5-flash-lite 1 round trip 1 round trip
Google Vertex AI anthropic/claude-* 1 (known from the ID) 1 round trip :rawPredict (OAuth2 required)

Two things get settled:

  • route — which surface holds it (chat / responses / messages / vertex-anthropic)
  • dialect — how to spell the request (some deployments reject max_tokens)

When neither is written (hand-written configurations, discovered models), they are learned at runtime as before. For an endpoint with a single surface and no dialect quirks, it is one round trip either way.

"Check connection" on an endpoint only establishes reachability and whether the key is accepted.

Azure OpenAI

  • The input is labelled "deployment name". Use the name as listed under "Deployments" in the portal, not the catalogue model name.
  • You can paste the portal's resource URL as-is. Entering https://<name>.openai.azure.com appends /openai/v1 automatically.

AWS Bedrock

Bedrock has an OpenAI-compatible endpoint that accepts a Bedrock API key via Authorization: Bearer. No SigV4 signing and no AWS CLI configuration are involved. Choose "AWS Bedrock" in the settings panel and enter a region and an API key (the base URL https://bedrock-mantle.<region>.api.aws/v1 follows from the region).

providers:
  bedrock:
    base_url: https://bedrock-mantle.ap-northeast-1.api.aws/v1   # us-east-1 when omitted
    key: secret:bedrock

models:
  - id: gpt-oss
    use: bedrock/openai.gpt-oss-120b

Things that trip people up:

  • There are three surfaces, and models live on different ones. All three sit on the same host. Writing just /v1 in the configuration is enough — the gateway sends models that are not there to the right surface automatically (measured in us-east-1 by calling each of the 55 listed models individually).

    Endpoint Protocol Models Notes
    /v1/chat/completions OpenAI compatible 38 open-weight models (openai.gpt-oss-*, qwen.*, deepseek.*, mistral.*, zai.* (GLM), moonshotai.* (Kimi), minimax.*, nvidia.*, google.gemma-3-*) takes max_tokens as-is
    /openai/v1/responses OpenAI Responses google.gemma-4-*, xai.grok-4.3, openai.gpt-5.x (*) rejects max_tokens, so it is rewritten to max_completion_tokens
    /anthropic/v1/messages Anthropic native 6 anthropic.claude-* models (*) authenticates with either x-api-key or Bearer

    (*) The account used for the measurements has no entitlement for these two groups (is not available for this account), so a successful round trip is unverified. The connection test reports "not available to this AWS account" in that case — it is not a key problem, so enable access on the AWS side.

    anthropic.claude-* is sent straight to the Anthropic surface based on the ID prefix. Both OpenAI-compatible surfaces refuse it, so trying them only wastes round trips. Other surfaces are settled once at registration and stored, so production requests always take one round trip.

    /v1/responses is not used — sending a model that /v1 refused there produces neither 200 nor 400 and never terminates, so delegation targets /openai/v1 instead.

  • Issue API keys in the management console under Bedrock -> "API keys". Short-term keys expire within 12 hours (this is the "it worked yesterday, now 401").

  • Model access has to be enabled per region. A model that is not enabled answers 403 AccessDeniedException. That is not a key problem, so the connection test says "enable model access".

  • Model IDs are vendor.model (openai.gpt-oss-120b, qwen.qwen3-32b, deepseek.v3.2). On the bedrock-mantle surface there is no region prefix — measured, all 55 IDs this surface returns take that form (adding us. gives a 400). The exact list is available from GET https://bedrock-mantle.<region>.api.aws/v1/models. Inference profile ARNs (arn:aws:bedrock:...:inference-profile/...) can be registered too.

  • The same model can appear both dated and undated (openai.gpt-5.5 and openai.gpt-5.5-2026-04-23). Either is fine to write, though being listed still does not mean being callable (as above).

  • Delegation is decided from the upstream's refusal. Both model '...' isn't supported on this route and The model '...' does not support the '/v1/...' API are sent to the Responses surface of the other endpoint (/openai/v1). Only when both refuse does it end with "cannot be called on this endpoint". The discovered route is cached per model, so later calls take one round trip.

  • API keys are bound to a region. Sending one to another region's endpoint gives 401 Credential should be scoped to a valid region. The key is fine, so the connection test says "the API key belongs to a different region than the endpoint".

Google Vertex AI

Vertex AI's OpenAI-compatible endpoint works with an Express-mode API key (one starting with AQ.). Choose "Google Vertex AI" in the settings panel and enter a project ID and an API key (the URL follows from them).

providers:
  vertex:
    # The project ID goes in the base URL, so this endpoint alone requires base_url.
    base_url: https://aiplatform.googleapis.com/v1/projects/my-project-123456/locations/global/endpoints/openapi
    key: secret:vertex

models:
  - id: flash-lite
    use: vertex/google/gemini-3.5-flash-lite

Things that trip people up:

  • It is not an AI Studio key. The "Google Gemini (AI Studio)" option is a different endpoint with a different key. Mixing them up gives a 401.
  • Model IDs are publisher/model (google/gemini-3.5-flash-lite). Sending just gemini-3.5-flash-lite gives 400 Malformed publisher model. Only the tail is used as the published name, so clients can call it as something short like flash-lite.
  • The location is global. Measured: the same Express key returns 200 on global while us-central1 answered Publisher model ... was not found or your project does not have access. There is deliberately no region picker.
  • The key goes only in x-goog-api-key. Sending Authorization: Bearer alongside gives 401 API keys are not supported by this API, so the "send both" approach used for other endpoints does not apply (auth: google-api-key switches this).
  • "Check connection" uses no model list. Vertex AI has none, and /models returns 404 HTML whether or not the key is valid. Instead an empty body is posted to /chat/completions, distinguishing 400 (the key was accepted) from 401.

Partner models (Claude and friends) differ in both endpoint and credential

Vertex splits Google's own models from partner models (measured).

Model Endpoint Format Credential
google/gemini-* .../endpoints/openapi/chat/completions OpenAI compatible API key (x-goog-api-key)
anthropic/claude-* .../publishers/anthropic/models/<model>:rawPredict
(:streamRawPredict when streaming)
Anthropic Messages OAuth2 access token (Bearer)

Model IDs starting with anthropic/ are routed to the partner-model endpoint automatically (settled at registration as route: vertex-anthropic). The body is Messages format, but model goes in the URL and is not sent; anthropic_version: vertex-2023-10-16 takes its place.

An API key cannot call it. Measured: sending x-goog-api-key gives 401 API keys are not supported by this API, and putting an API key in Bearer gives 401 Request had invalid authentication credentials. Expected OAuth 2 access token (identical on global and us-east5). The connection test reports this as "cannot be called with an API key (an OAuth2 access token is required)". To use it, register the output of gcloud auth print-access-token as the API key (it expires in about an hour).

Unverified: the measurement environment has neither Claude entitlement on the project nor OAuth2 credentials, so a successful round trip has not been confirmed. What is confirmed is that requests reach the right endpoint, the shape of the body, and the error handling. Other partner models (Meta, Mistral, ...) are out of scope: each publisher has its own body format, and the Anthropic shape does not work for them.

When you do want to spell things out

providers:
  mycorp:                              # a provider with no preset
    type: openai-compatible
    base_url: https://llm.example.com/v1
    key: secret:mycorp
    headers: { X-Tenant: dev }

models:
  - id: fast                           # a shorter published name
    use: ollama/qwen3-coder:30b
    max_output_tokens: 32768

aliases:                               # map the fixed IDs Claude Code sends
  "*haiku*": fast

server: { port: 4100, auth: none }

Aliases

Claude Code sends fixed model IDs such as claude-opus-5. Globs map them onto real models. The resolution order is

exact match -> user globs -> built-in globs (claude-*, gpt-*, o3*, ...) -> default

Supported protocols

Inbound (client side)

Endpoint Format Main consumer
POST /v1/messages Anthropic SSE Claude Code
POST /v1/messages/count_tokens Anthropic (estimated) Claude Code
POST /v1/responses OpenAI Responses SSE Codex (wire_api can only be responses)
POST /v1/chat/completions OpenAI SSE Copilot and other OpenAI-compatible clients
GET /v1/models OpenAI Codex / Copilot
POST /api/chat Ollama NDJSON Copilot BYOK
GET /api/tags, POST /api/show, GET /api/version Ollama Copilot BYOK

Outbound (upstream side)

type Targets
openai-compatible Ollama, LM Studio, llama.cpp, vLLM, OpenAI, OpenRouter, DeepSeek, Groq, Azure, AWS Bedrock, Google Vertex AI, ...
openai-responses endpoints that only offer the Responses API
anthropic the Anthropic Messages API

openai-compatible absorbs dialect differences at runtime:

  • upstreams that reject max_tokens are switched to max_completion_tokens automatically (measured: Azure's gpt-5.6-sol, AWS Bedrock's /openai/v1)
  • Unsupported parameter: 'x' drops that field and retries exactly once (some deployments do not accept reasoning.effort)
  • when /chat/completions does not carry the model, it is delegated to the Responses API (Azure's 404 api_not_supported, AWS Bedrock's 400 ... isn't supported on this route. Bedrock serves a different model set per route, so without this some models would be uncallable)
  • delegation prefers another surface on the same host. AWS Bedrock has /v1, /openai/v1 and /anthropic side by side, and a model /v1 refused never terminates on /v1/responses while /openai/v1/responses answers (measured). IDs starting with anthropic. go to the Anthropic-native surface (/anthropic/v1/messages), so one endpoint spans three protocols

All of this is established once, at registration, and stored in the configuration as route and dialect (see registering a model). Without it in the configuration, it is learned and cached in process — but that dies with a restart, so settling it at registration is the main path.

Key placement is switched per upstream too (the default sends both Authorization: Bearer and api-key; Vertex AI alone uses x-goog-api-key on its own). See Google Vertex AI.

Stall timeouts

A silent upstream never leaves a client waiting indefinitely.

Path Limit How it is counted
Streaming 120 s restarted on every event (a long generation is itself normal, so capping total time would kill ordinary responses)
Non-streaming 300 s the whole round trip, since there is no intermediate signal
Endpoint "Check connection" 8 s —
Per-model connection test 60 s large local models can take tens of seconds to load
Local discovery at startup 1.5 s so startup is not blocked. The result is reused — re-probing four ports on every configuration write would make registering a single model feel slow (an explicit "Rediscover Models" repeats it)

Measured: for one model, AWS Bedrock's /v1/responses returned response.created and then sent nothing but SSE keep-alives (: lines), with no terminator after seven minutes. Keep-alives are discarded per the specification, so from the gateway it looked completely silent. Previously only the connection test had a deadline and the production path had none, which left clients hanging.

Debug logging

For investigating problems, request and response bodies can be recorded in the output channel. There are four ways to toggle it, and none of them needs a restart.

Place Action
Settings panel (the "server" row in step 3) Debug logging: do not record / record bodies too, then Apply
Status bar menu not listed (it keeps to everyday actions)
Command palette Model Gate: Toggle Debug Logging
VS Code settings modelgate.debug

While it is on, prompt and response contents are retained verbatim. They may contain work code or personal data, so enabling it says so on screen once. API keys and authentication tokens are never logged on any path (headers are not recorded).

Usage records

One line per request is appended to ~/.modelgate/usage/YYYY-MM-DD.jsonl (kept 365 days by default). The retention matches the heatmap window: a shorter one would make "empty because it was deleted" indistinguishable from "empty because it was unused" (change it with usage.retention_days). JSONL rather than SQLite, to avoid a native dependency.

Recorded: client, inbound protocol, requested model, resolved model, provider, upstream model, input/output tokens, cache tokens, measured or estimated, cost, latency, TTFT, status, aborted flag.

Two design contracts:

  • Measured and estimated values are never mixed. When the upstream reports no usage, it falls back to estimation, sets tokenSource: 'estimated' and shows it distinctly in the UI.
  • Unknown pricing is never treated as zero. Local inference really is free, but a cloud model without pricing is undefined and displays as "cost unknown".

OpenAI-compatible streams report no usage without stream_options.include_usage, so the gateway always adds it upstream (and does not forward a usage chunk downstream when the client did not ask for one).

Commands

Command What it does
Model Gate: Start / Stop / Restart Server control the gateway
Model Gate: Open Usage the usage panel
Model Gate: Open Settings Panel edit providers, models and the port
Model Gate: Open Configuration File edit the YAML directly
Model Gate: Register API Key store a key in SecretStorage
Model Gate: Set Up Clients write the settings into each client
Model Gate: Rediscover Models rescan for local models
Model Gate: Open Log the output channel
Model Gate: Toggle Debug Logging switch body-level logging (see above)

Development

npm install
npm run typecheck   # type checking
npm test            # automated tests (273, no external traffic; includes activating the artefact)
npm run build       # produce dist/extension.js (bundling the ESM builds of dependencies)
npm run watch       # watch build

Release steps

npm run typecheck && npm test        # 273 tests (including activating the artefact)
npm run package                      # model-gate-<version>.vsix
unzip -l model-gate-*.vsix            # confirm testcon.txt and .claude are absent
git tag -a v<version> -m "Model Gate <version>"

npm run icon regenerates the icon (media/icon.png, 256x256) via scripts/make-icon.py. It needs no external libraries: the shapes are signed-distance fields and the label is filled straight from a TrueType outline by scripts/ttf.py. Any bold grotesque on the machine will do — the script tries a list of usual paths and tells you if it finds none. Before publishing to the Marketplace, change publisher to a registered ID, set repository to the public URL, and replace the copyright holder in LICENSE with a real name.

Verifying against real endpoints

Place testcon.txt (line 1 base_url, line 2 API key) to run the scripts that exercise real endpoints. That file is gitignored.

npm run compile
node out/test/manual/live.js   # the provider layer only
node out/test/manual/e2e.js    # the whole gateway (3 protocols + separated attribution)

Design notes

Translation is N+M, not N×M. Rather than combining 4 inbound protocols with 3 upstream kinds directly, everything goes through an internal representation (IR). The IR follows Anthropic's block-oriented shape, because OpenAI's flat delta format can be derived from it mechanically while the reverse (flat -> blocks) cannot recover the boundaries.

Three things break most easily in translation (pinned by tests):

  1. Where tool_result goes — Anthropic puts it in a block array inside a user message, OpenAI in standalone role: 'tool' messages. N of them in one turn have to expand into or collapse from N messages. Forwarding them split up breaks parallel tool calls.
  2. OpenAI's tool_calls[].index — it numbers within the tool_calls array and does not match the IR block index.
  3. output_tokens in Anthropic's message_delta is cumulative — adding it up double-counts.

Ollama is NDJSON, not SSE, and does not accept chunked tool calls (arguments are buffered and emitted in one message).

  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
© 2026 Microsoft