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
26 changes: 26 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,34 @@ env:
CARGO_TERM_COLOR: always

jobs:
verify:
name: Verify tag matches Cargo.toml
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.tag || github.ref }}

# A release whose binary reports a different version than its tag makes
# the built-in auto-upgrade re-download the same release on every start,
# because `current_version` never catches up with the tag. Fail loudly
# instead of publishing that.
- name: Compare tag with package version
shell: bash
run: |
tag="${{ github.event.inputs.tag || github.ref_name }}"
crate_version=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
echo "tag=$tag crate_version=$crate_version"
if [ "$tag" != "v$crate_version" ]; then
echo "::error::Tag $tag does not match Cargo.toml version $crate_version." \
"Bump Cargo.toml (and Cargo.lock) before tagging."
exit 1
fi

build:
name: Build ${{ matrix.target }}
needs: verify
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
Expand Down
149 changes: 149 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,155 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [1.3.0] - 2026-07-27

### Added
- `GET /health` liveness/readiness probe reporting version, uptime, Copilot
token status and remaining lifetime, loaded model count, requests served, and
whether API-key auth is enabled. Answers without contacting upstream and is
never guarded by the API key; `?strict=true` returns `503` when not ready
- `GET /v1/models/{model}` (and `/models/{model}`) OpenAI-compatible single
model retrieval, including `capabilities` and `supported_endpoints`. Model
aliases from `model_mappings` are resolved; unknown ids return `404`
- Graceful shutdown on Ctrl-C and `SIGTERM`, draining in-flight requests and SSE
streams instead of dropping them
- `ghc_proxy_uptime_seconds` gauge on `/metrics`
- `model=` filter on `GET /api/audit`, matching the requested or translated model
- `/health` and `/v1/models/{model}` documented in `openapi.json`
- **SSE keepalive.** Streams emit a `: keepalive` comment after 15s of silence
so an idle connection is not dropped at the ~60s timeout GitHub's upstream
load balancer and most intermediaries enforce. Extended thinking can easily
exceed that with no tokens emitted, which previously surfaced as
`user_request_timeout` or a stream that died mid-answer. The comment is only
injected at an event boundary, so the verbatim passthrough paths are never
spliced mid-event
- **`anthropic-beta` request headers are forwarded.** The client's flags are
preserved and merged with the ones the proxy derives, instead of being
overwritten by the 1M-context flag
- Missing `max_tokens` on `/v1/chat/completions` is filled from the model
catalog's `capabilities.limits.max_output_tokens`, which several Copilot
models reject the request without
- Requesting a `/responses`-only model on `/v1/chat/completions` now returns an
actionable `unsupported_api_for_model` error naming the right endpoint,
instead of an opaque upstream 400

### Fixed
- **`context_management` was silently stripped, disabling Claude Code's context
editing.** The field was not in the Anthropic passthrough allowlist, and the
upstream rejects it with a misleading `Extra inputs are not permitted` 400
unless the `context-management-2025-06-27` beta is requested. It is now
forwarded and the matching beta flag is derived automatically
- **`tool_result` content arrays were forwarded raw, breaking every MCP tool
that returns anything but a bare string.** Anthropic allows `content` to be an
array of blocks, which MCP servers routinely use; passing it through made the
upstream reject the whole request with
`type has to be either 'image_url' or 'text'`. Text blocks are now joined and
image blocks rewritten as `image_url` data URLs
- **Images nested inside a `tool_result` did not set `Copilot-Vision-Request`.**
`has_image` only inspected top-level content blocks, so screenshots returned
by MCP tools were sent as non-vision requests
- `image` blocks with a `url` source (rather than `base64`) are now translated
instead of being turned into an empty data URL
- Models that replaced `max_tokens` with `max_completion_tokens` (`gpt-5.3-codex`
and friends) previously failed outright; the parameter is renamed and the
request retried once, on both the streaming and non-streaming paths
- **Stopped scraping the Arch User Repository for the latest VS Code version.**
The AUR maintainers asked proxies of this kind to stop, having become the
single most-requested endpoint on their service. `dynamic_vscode_version` now
uses Microsoft's own `update.code.visualstudio.com` release API and ignores
non-`major.minor.patch` builds
- The local `/v1/messages/count_tokens` estimate ignored per-message framing and
tool schemas, under-reporting badly enough that Claude Code compacted too late
and then hit a hard `prompt token count exceeds the limit` failure. Tool
definitions are now counted as serialized JSON, plus per-message and
per-request overhead
- **`/v1/messages/count_tokens` always failed with HTTP 400.** The handler only
tried the upstream when the catalog advertised `/v1/messages/count_tokens`,
which Copilot never does, and then returned an error. It now forwards to the
upstream for any model exposing the native `/v1/messages` surface (returning
exact counts) and falls back to a local tiktoken estimate — marked
`"estimated": true` — instead of erroring. This unblocks Claude Code, which
calls the endpoint before every request
- **Streaming `/v1/responses` returned upstream errors as a `200` SSE body.**
A 401/429/5xx from upstream was wrapped in a success stream, so clients saw a
broken response instead of the real failure. Non-2xx upstream responses are
now surfaced with their status code, matching the other streaming paths
- **Cost estimates were wrong for mapped models and for `gpt-4o`.** Costs were
computed from the client-supplied model name rather than the model actually
served, so an alias such as `opus` priced at the fallback rate (~38x too low).
The `gpt-4o` rate was also unreachable because the broader `gpt-4` arm matched
first. Rates now key off the translated model, order specific families before
general ones, strip `publisher/` prefixes for GitHub Models ids, and cover the
gpt-4.1/gpt-5/o-series/Gemini families
- Dashboard pagination could overflow `usize` (panicking the handler) and an
unbounded `per_page` forced a full clone of the request store; `page`/`per_page`
are now parsed with saturating arithmetic and clamped to 500
- `error.log` grew without limit; it is now rotated to `error.log.1` past 8 MB
- The upstream HTTP client had no connect timeout, so a dead upstream could wedge
a request indefinitely (30s connect timeout, 90s pool idle timeout; no overall
request timeout so SSE streams are unaffected)
- The model catalog was fetched twice at startup because the periodic refresh
timer fired immediately on its first tick
- `/metrics` and `/api/audit*` cloned every retained record (including captured
request/response bodies in debug mode) on each call; aggregation and filtering
now happen in place under the store lock
- Removed a dead `is_github_models` binding in the translated Anthropic path

#### Streaming integrity
- **Streaming responses could be silently corrupted mid-text.** Every SSE chunk
was decoded with `String::from_utf8_lossy` as it arrived, but upstream chunks
do not respect UTF-8 character boundaries. Any multi-byte character (CJK,
emoji, `—`, box drawing) split across two chunks became `U+FFFD`, and because
the damaged payload then failed to parse as JSON it was **dropped entirely**,
deleting text from the middle of a response with no error. The corrupted or
missing text was then replayed into the next request as conversation history.
SSE parsing now buffers raw bytes and only decodes complete lines
(`util::SseLineBuffer`), matching the `TextDecoderStream` + `TextLineStream`
pipeline the TypeScript `copilot-api` proxy gets from its platform
- **Truncated streams were delivered as if they were complete.** A transport
error mid-stream only did `break`, so the client received a partial answer
with no terminator and the request was still recorded as `200`. All five
streaming paths now detect the interruption, emit a protocol-appropriate
terminator, and record the request as `502`:
- OpenAI: `data: {"error": …, "code": "stream_interrupted"}` followed by `[DONE]`
- Anthropic: `event: error` with an `api_error` payload
- Responses: `event: error` with `code: "stream_interrupted"`
- Gemini: final chunk with `finishReason: "OTHER"`
- **Anthropic streams that ended without a `finish_reason` never sent
`message_stop`.** Claude Code either blocks waiting for it or keeps the
partial text as a finished assistant turn. `AnthropicStreamState::finish()`
now closes any open content block and emits `message_delta` + `message_stop`
- A final SSE event arriving without a trailing newline was discarded; the line
buffer is now flushed when the stream ends
- `/v1/chat/completions` streaming dropped any `data:` payload that failed to
parse as JSON instead of forwarding it
- `/v1/responses` streaming now also flags a stream that ends before
`response.completed`
- Direct-Anthropic streaming extracted `stop_reason` and `tools_called` by
re-scanning the captured response body, so those audit fields were only ever
populated in debug mode; they are now collected while the stream is parsed
- SSE `data:` parsing accepts `data:{…}` (no space) per the SSE spec, not only
`data: {…}`

### Changed
- `/api/audit/summary` now rounds cost figures to 4 decimals so small per-request
costs are no longer reported as `0.0`
- Default `vscode_version` bumped to `1.130.0`
- The release workflow now fails if the tag does not match the `Cargo.toml`
version. `v1.2.3` shipped with `version = "1.2.2"`, so binaries from that
release report `1.2.2` and `auto_upgrade` re-downloaded it on every start

## [1.2.3] - 2026-07-20

### Fixed
- `--setup --claudecode` no longer overwrites unrelated Claude Code settings
([#24](https://github.com/MartinForReal/ghc-proxy/pull/24))

### Known issue
- Released with `Cargo.toml` still at `1.2.2`, so binaries from this release
identify themselves as `1.2.2`. With `auto_upgrade` enabled they re-download
this release on every start. Upgrade to 1.3.0 to clear it.

## [1.2.2] - 2026-07-14

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ghc-proxy"
version = "1.2.2"
version = "1.3.0"
edition = "2021"
description = "GitHub Copilot API Proxy - Provides OpenAI and Anthropic compatible endpoints via GitHub Copilot (Rust port of ghc-tunnel)"
license = "MIT"
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,13 @@ the dashboard and model listings.
| `POST /v1/chat/completions` | OpenAI chat completions |
| `POST /v1/responses` | OpenAI responses API (Codex) |
| `GET /v1/models` | List available models |
| `GET /v1/models/{model}` | Retrieve a single model (aliases resolved) |
| `POST /v1/messages` | Anthropic messages API |
| `POST /v1/messages/count_tokens` | Anthropic token counting |
| `POST /v1/messages/count_tokens` | Anthropic token counting (local estimate fallback) |
| `POST /v1beta/models/{model}:generateContent` | Gemini generate content |
| `POST /v1beta/models/{model}:streamGenerateContent` | Gemini streaming (SSE) |
| `POST /v1beta/models/{model}:countTokens` | Gemini token counting |
| `GET /health` | Liveness/readiness probe (`?strict=true` for 503 when not ready) |
| `GET /openapi.json` | OpenAPI v3 specification |
| `GET /` | Web dashboard |
| `GET /metrics/dashboard` | Metrics dashboard UI |
Expand Down
107 changes: 106 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@ require a key on the LLM endpoints; see [Authentication](#authentication) below.
| `POST /v1/chat/completions` | OpenAI chat completions (also `/chat/completions`) |
| `POST /v1/responses` | OpenAI Responses API for Codex (also `/responses`) |
| `POST /v1/messages` | Anthropic Messages API |
| `POST /v1/messages/count_tokens` | Anthropic token counting (real BPE) |
| `POST /v1/messages/count_tokens` | Anthropic token counting (real BPE, local estimate fallback) |
| `POST /v1beta/models/{model}:generateContent` | Gemini generate content |
| `POST /v1beta/models/{model}:streamGenerateContent` | Gemini streaming (SSE) |
| `POST /v1beta/models/{model}:countTokens` | Gemini token counting |
| `POST /v1/embeddings` | Embeddings (also `/embeddings`) |
| `GET /v1/models` | List available models (also `/models`, `/api/models`) |
| `GET /v1/models/{model}` | Retrieve a single model (also `/models/{model}`) |
| `GET /v1/models/full/` | Raw upstream model catalog with capabilities |
| `GET /usage` | Copilot plan and quota usage |
| `GET /health` | Liveness/readiness probe |
| `GET /` | Web analytics dashboard |
| `GET /metrics/dashboard` | Metrics dashboard UI |
| `GET /metrics` | OpenMetrics exposition endpoint |
Expand All @@ -45,6 +47,58 @@ Streaming (SSE) is supported on the chat, responses, and messages endpoints by
setting `"stream": true` in the request body. The Gemini surface streams via the
dedicated `:streamGenerateContent` action.

## Health check

`GET /health` answers without contacting the upstream, so it is cheap enough for
a service supervisor or container probe to poll frequently. It is never guarded
by the optional API key.

```bash
curl http://127.0.0.1:8314/health
```

```json
{
"status": "ok",
"ready": true,
"version": "1.3.0",
"uptime_seconds": 128,
"copilot_token": { "present": true, "expires_in_seconds": 1487 },
"models_loaded": 77,
"requests_served": 42,
"auth_required": false
}
```

`ready` is true once a Copilot token has been obtained **and** the model catalog
has loaded. A degraded proxy still answers `200` with `ready: false` so probes
can distinguish "process alive" from "able to serve traffic". Add `?strict=true`
to get `503 Service Unavailable` instead when the proxy is not ready.

## Retrieve a model

`GET /v1/models/{model}` returns a single catalog entry in the OpenAI shape,
including the raw `capabilities` and `supported_endpoints` reported upstream.
Model aliases from `model_mappings` are resolved, so `/v1/models/opus` returns
the mapped Copilot model. Unknown ids return `404` with an OpenAI-style error
body.

```bash
curl http://127.0.0.1:8314/v1/models/claude-opus-4.8
```

## Token counting

`POST /v1/messages/count_tokens` forwards to the upstream Anthropic
`count_tokens` endpoint for models that expose the native `/v1/messages`
surface, returning exact counts. For every other model (and whenever the
upstream call fails) the proxy falls back to a local tiktoken estimate using the
tokenizer advertised in the model catalog. Estimated responses are marked:

```json
{ "input_tokens": 812, "estimated": true }
```

## OpenAI SDK

```python
Expand Down Expand Up @@ -131,6 +185,23 @@ curl http://127.0.0.1:8314/v1/models/full/
This is the authoritative source for which models support a 1M-token context
window (those advertising `max_context_window_tokens` greater than 200,000).

## Audit API

`GET /api/audit` returns recent request records with their extracted audit
fields. All filters are optional and combine with AND:

| Parameter | Effect |
|-----------|--------|
| `endpoint` | Substring match on the endpoint path |
| `status` | Exact HTTP status code |
| `tool_name` | Keeps records whose request offered a matching tool |
| `agent` | `true`/`false` — agent- vs user-initiated requests |
| `model` | Substring match on the requested or translated model |
| `page`, `per_page` | Pagination (`per_page` is clamped to 500) |

`GET /api/audit/summary` aggregates the same records into top tools, stop-reason
counts, estimated cost, and prompt-cache hit rate.

## Notable behaviors

- **GitHub Models routing** — when enabled (default), requests whose translated
Expand All @@ -152,5 +223,39 @@ window (those advertising `max_context_window_tokens` greater than 200,000).
- **Adaptive-thinking migration** — when an upstream model rejects
`thinking.type = "enabled"`, the proxy automatically retries using the
adaptive format.
- **Upstream errors are never disguised as streams** — a non-2xx upstream
response on a `"stream": true` request is returned as a normal error response
with the upstream status code, not as a `200` SSE body.
- **Interrupted streams are reported, not silently truncated** — if the upstream
connection drops mid-response, the proxy emits a protocol-appropriate
terminator (`data: {"error": …}` + `[DONE]` for OpenAI, `event: error` for
Anthropic and Responses, `finishReason: "OTHER"` for Gemini) and records the
request as `502`. Anthropic streams that end without a `finish_reason` are
also closed with `message_stop`, so clients never block on a half-open
message or keep a partial answer as a completed turn.
- **Byte-exact streaming** — SSE parsing buffers raw bytes and decodes only
complete lines, so multi-byte characters split across network chunks are
never mangled into `U+FFFD` or dropped.
- **SSE keepalive** — a `: keepalive` comment is emitted after 15 seconds of
silence so extended thinking does not trip the ~60 second idle timeout
enforced by the upstream load balancer. Comments are ignored by every
spec-compliant SSE client.
- **`anthropic-beta` passthrough** — the client's beta flags are forwarded and
merged with the ones the proxy derives (`context-1m-2025-08-07` for extended
context models, `context-management-2025-06-27` when the request uses
`context_management`).
- **MCP tool results** — a `tool_result` whose `content` is an array of blocks
is normalized before translation: text blocks are joined and image blocks
become `image_url` data URLs. Images nested there also enable the vision
header.
- **Parameter migration** — a model that rejects `max_tokens` in favour of
`max_completion_tokens` is retried once with the renamed parameter, and a
missing `max_tokens` is filled from the model catalog.
- **Cost estimates use the served model** — the `estimated_cost_usd` field and
the `ghc_proxy_estimated_cost_usd_total` metric price requests using the model
actually sent upstream (after translation), not the alias the client asked
for.
- **Graceful shutdown** — on Ctrl-C (or `SIGTERM` on Unix) the proxy stops
accepting connections and lets in-flight requests and SSE streams finish.
- **Content filtering** — system-prompt add/remove and tool-result suffix
removal are applied per your configuration.
Loading