diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43f7374..05cd801 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+### Fixed
+- **`sonnet` now maps to Sonnet, not Opus.** The default model mappings folded
+ every Claude spelling — Sonnet included — into the newest Opus. That is
+ right for aliases actually named after a Claude version Copilot dropped, but
+ `sonnet` and every `claude-sonnet-*` spelling now resolve to `claude-sonnet-5`
+ instead, so a caller that asked for the mid tier gets its cost and rate
+ limits rather than being silently upgraded to Opus.
+
+### Removed
+- Removed GitHub Models routing, catalog merging, token configuration, and setup
+ now that the GitHub Models inference service has been retired. All model
+ requests now use GitHub Copilot.
+
## [1.4.3] - 2026-08-09
### Added
diff --git a/README.md b/README.md
index 521913d..6d0c08a 100644
--- a/README.md
+++ b/README.md
@@ -49,11 +49,6 @@ file.
as `response_format`. `topK` and `safetySettings` have no counterpart and are
dropped, which the dashboard states rather than leaving you to assume they
took effect.
-- **GitHub Models inference** — requests whose model id uses the
- `publisher/model` form (e.g. `openai/gpt-4o`) are transparently routed to the
- [GitHub Models](https://models.github.ai) API instead of Copilot, authenticated
- with a token that has the `models: read` permission. Enabled by
- default; the catalog is merged into `/v1/models`.
- **Optional API-key authentication** on the LLM endpoints
(`Authorization: Bearer`, `x-api-key`, or `x-goog-api-key`), disabled by
default and compared in constant time.
@@ -134,11 +129,7 @@ The GitHub token is exchanged for a short-lived **Copilot token** via
`https://api.github.com/copilot_internal/v2/token`, which is refreshed
automatically before it expires.
-The interactive Device Flow requests the `read:user copilot` scopes. GitHub
-Models is **not** covered by the Device Flow token (`models` is not a valid
-classic OAuth scope). To use the [GitHub Models](#github-models) inference API,
-supply a dedicated token with the `models: read` permission (a fine-grained PAT)
-via `github_models.token`.
+The interactive Device Flow requests the `read:user copilot` scopes.
## Endpoint Authentication
@@ -180,7 +171,7 @@ Config file: `~/.ghc-tunnel/config.yaml` (`%APPDATA%/ghc-tunnel/config.yaml`
on Windows). It is generated on first run or with `--config`.
```yaml
-config_version: 4
+config_version: 6
address: 127.0.0.1
port: 8314
debug: false
@@ -192,14 +183,10 @@ auto_upgrade: true # self-update on startup; false to disable
model_mappings:
exact:
opus: claude-opus-5
- sonnet: claude-opus-5
+ sonnet: claude-sonnet-5
haiku: claude-haiku-4.5
prefix:
- claude-sonnet-4-: claude-opus-5
-github_models:
- enabled: true # route publisher/model ids to GitHub Models
- # org: my-org # attribute inference to an organization
- # token: ghp_xxx # dedicated token (models: read permission)
+ claude-sonnet-4-: claude-sonnet-5
system_prompt_remove: []
system_prompt_add: []
tool_result_suffix_remove: []
@@ -211,52 +198,6 @@ upstream_read_timeout_seconds: 900 # max silence from upstream; 0 disables
# api_key: my-secret-key
```
-## GitHub Models
-
-Besides Copilot, GitHub offers a separate model **inference** service —
-[GitHub Models](https://models.github.ai) — exposing OpenAI-compatible endpoints
-for models from OpenAI, Meta, Mistral, xAI, DeepSeek, and others. This proxy
-routes to it transparently.
-
-**Routing.** GitHub Models identifies models by a `publisher/model` id (e.g.
-`openai/gpt-4o`, `meta/llama-4-maverick`). When `github_models.enabled` is true
-(the default), any request whose *translated* model id contains a `/` is sent to
-GitHub Models instead of Copilot. Because Copilot model ids never contain a `/`,
-the two never collide, and existing model mappings are unaffected. This works on
-`/v1/chat/completions`, `/v1/messages` (translated), and the Gemini endpoints.
-
-```bash
-curl http://127.0.0.1:8314/v1/chat/completions \
- -H "Content-Type: application/json" \
- -d '{"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi!"}]}'
-```
-
-**Authentication.** GitHub Models uses the raw GitHub token (not the Copilot
-token) via `Authorization: Bearer`. The token must carry the **`models: read`**
-permission (a fine-grained PAT). The token minted by the Device Flow does **not**
-have this permission (`models` is not a valid classic OAuth scope), so set a
-dedicated token via `github_models.token` or the
-`GHC_PROXY_GITHUB_MODELS_TOKEN` environment variable. Tokens without the
-permission get an `Unauthorized` response from GitHub.
-
-**Configuration.**
-
-```yaml
-github_models:
- enabled: true # set false to always use Copilot
- org: my-org # optional: attribute inference to an organization
- token: ghp_xxx # optional: dedicated token (models: read permission)
-```
-
-| Environment variable | Effect |
-|----------------------|--------|
-| `GHC_PROXY_GITHUB_MODELS_ENABLED` | Enable/disable routing (`true`/`1`) |
-| `GHC_PROXY_GITHUB_MODELS_ORG` | Attribute inference to an organization |
-| `GHC_PROXY_GITHUB_MODELS_TOKEN` | Dedicated token for GitHub Models |
-
-The GitHub Models catalog is merged into `GET /v1/models` so those ids show up in
-the dashboard and model listings.
-
## API Endpoints
| Endpoint | Description |
@@ -271,6 +212,9 @@ the dashboard and model listings.
| `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/full/` | Raw upstream model catalog with capabilities |
+| `GET /usage` | Copilot plan and quota usage (also `check-usage`) |
| `GET /health` | Liveness/readiness probe (`?strict=true` for 503 when not ready) |
| `GET /openapi.json` | OpenAPI v3 specification |
| `GET /` | Web dashboard — overview |
@@ -282,6 +226,9 @@ the dashboard and model listings.
| `POST /api/config/debug` | Turn body capture on or off (`{"debug": true}`) |
| `GET /api/stats` | Running totals, including what Copilot billed |
| `GET /api/cache` | Prompt-cache statistics, overall and per model |
+| `GET /api/requests` | Recent requests (JSON) |
+| `GET /api/audit` | Filtered audit records |
+| `GET /api/audit/summary` | Aggregated audit summary |
| `GET /api/models` | All supported models (used by the dashboard) |
The `/api/config/` routes are guarded by the API key when one is configured;
@@ -335,6 +282,7 @@ cargo clippy # lint
| File | Responsibility |
|------|----------------|
| `src/main.rs` | CLI parsing and server startup |
+| `src/lib.rs` | Library crate root re-exporting the modules below |
| `src/setup.rs` | Interactive first-run setup wizard |
| `src/config.rs` | Config dir, YAML config, defaults, model-mapping defaults |
| `src/auth.rs` | GitHub token resolution (env/file/Device Flow), Copilot token exchange |
diff --git a/docs/api.md b/docs/api.md
index ba8b38a..2c4d538 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -357,11 +357,6 @@ counts, estimated cost, and prompt-cache hit rate.
## Notable behaviors
-- **GitHub Models routing** — when enabled (default), requests whose translated
- model id uses the `publisher/model` form (e.g. `openai/gpt-4o`) are routed to
- the [GitHub Models](https://models.github.ai) inference API instead of Copilot,
- authenticated with a token that has the `models: read` permission.
- See [Configuration](configuration.md#github-models).
- **Model translation** — model names are rewritten per your
[mappings](configuration.md#model-mappings) before being forwarded.
- **1M context** — for Anthropic-native requests, the proxy forwards the
diff --git a/docs/claude-code.md b/docs/claude-code.md
index e133bb6..6794621 100644
--- a/docs/claude-code.md
+++ b/docs/claude-code.md
@@ -63,9 +63,10 @@ model_mappings:
claude-opus-4-8: claude-opus-5
```
-The built-in defaults already do this for every Claude spelling. They apply to
-a *new* config file only — an existing one keeps the targets it has, so if you
-wrote yours before Opus 5 shipped, either edit it or re-run `--setup`.
+The built-in defaults already do this for every Opus and Sonnet spelling. They
+apply to a *new* config file only — an existing one keeps the targets it has,
+so if you wrote yours before Opus 5 shipped, either edit it or re-run
+`--setup`.
Restart the proxy after editing `config.yaml` — mappings are read at startup.
diff --git a/docs/configuration.md b/docs/configuration.md
index c9eb8cc..a18c9a0 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -21,7 +21,7 @@ Windows). Generated on first run, with `--config`, or through the setup wizard.
```yaml
# Schema version for migration/write-back behavior
-config_version: 4
+config_version: 6
# Server settings
address: 127.0.0.1
@@ -46,15 +46,7 @@ model_mappings:
sonnet: claude-sonnet-5
haiku: claude-haiku-4.5
prefix:
- claude-sonnet-4-: claude-opus-5
-
-# GitHub Models (https://models.github.ai) inference
-# Route publisher/model ids (e.g. openai/gpt-4o) to GitHub Models instead of
-# Copilot. Needs a token with the `models: read` permission (fine-grained PAT).
-github_models:
- enabled: true
- # org: my-org
- # token: ghp_xxx
+ claude-sonnet-4-: claude-sonnet-5
# Content filtering
system_prompt_remove: []
@@ -85,8 +77,9 @@ Incoming model names are rewritten before the request is forwarded upstream:
Exact matches take priority over prefix matches. Unmapped names pass through
unchanged. Use the live catalog at `GET /v1/models` to discover valid targets.
-The built-in mappings point every Claude spelling at the newest generally
-available Opus — currently `claude-opus-5` — and every Haiku spelling at
+The built-in mappings point every Opus spelling at the newest generally
+available Opus — currently `claude-opus-5` — every Sonnet spelling at the
+newest Sonnet (`claude-sonnet-5`), and every Haiku spelling at
`claude-haiku-4.5`. Anthropic writes the same version two ways (`4.8` and
`4-8`), so both forms are listed.
@@ -105,26 +98,6 @@ Controls the upstream base URL only:
Set this to match the Copilot seat your token actually has.
-### GitHub Models
-
-[GitHub Models](https://models.github.ai) is GitHub's OpenAI-compatible model
-**inference** service, separate from Copilot. When `github_models.enabled` is
-true (the default), any request whose *translated* model id uses the
-`publisher/model` form (contains a `/`, e.g. `openai/gpt-4o`) is routed there
-instead of Copilot. These ids never collide with Copilot ids, so mappings and
-existing behavior are unaffected. Routing applies to `/v1/chat/completions`,
-`/v1/messages` (translated), and the Gemini endpoints.
-
-GitHub Models authenticates with the **raw GitHub token** (not the Copilot
-token) via `Authorization: Bearer`. That token must carry the **`models: read`**
-permission (a fine-grained PAT). This is **not** covered by the Device Flow login
-(`models` is not a valid classic OAuth scope), so supply a dedicated token via
-`github_models.token` (or `GHC_PROXY_GITHUB_MODELS_TOKEN`); otherwise GitHub
-Models requests fall back to the Device Flow token, which lacks this permission.
-The [setup wizard](getting-started.md#the-setup-wizard) can capture and validate
-this token for you. Set `github_models.org` to attribute inference to an
-organization. The catalog is merged into `GET /v1/models`.
-
### Schema upgrades
`config_version` records the schema an existing `config.yaml` was written
@@ -147,6 +120,8 @@ Schema versions so far:
| 2 | Opus 4.8 aliases |
| 3 | `upstream_read_timeout_seconds`; `auto_upgrade` defaulting to true |
| 4 | Opus 5 and Sonnet 5 aliases |
+| 5 | Gemini CLI model mappings (`gemini-*` prefixes) |
+| 6 | Removed the retired GitHub Models settings from persisted configuration |
`--update-config` remains for the other, non-schema write-backs (for example
restoring the built-in `model_mappings` when the file has none).
@@ -208,9 +183,6 @@ Every config field has a `GHC_PROXY_*` override:
| `GHC_PROXY_RATE_LIMIT_WAIT` | Wait instead of rejecting when limited (`true`/`1`) |
| `GHC_PROXY_MANUAL_APPROVE` | Require manual approval per request (`true`/`1`) |
| `GHC_PROXY_API_KEY` | Require this key on LLM endpoints (empty = disabled) |
-| `GHC_PROXY_GITHUB_MODELS_ENABLED` | Route `publisher/model` ids to GitHub Models (`true`/`1`) |
-| `GHC_PROXY_GITHUB_MODELS_ORG` | Attribute GitHub Models inference to an organization |
-| `GHC_PROXY_GITHUB_MODELS_TOKEN` | Dedicated token for GitHub Models (`models: read` permission) |
Token-related variables (`COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`) are
covered in [Getting Started](getting-started.md#authentication).
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 8a40338..6184899 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -71,11 +71,7 @@ The GitHub token is exchanged for a short-lived **Copilot token** via
`https://api.github.com/copilot_internal/v2/token`, which the proxy refreshes
automatically before it expires.
-The Device Flow requests the `read:user copilot` scopes. The
-[GitHub Models](configuration.md#github-models) inference API is not covered by
-the Device Flow token (`models` is not a valid classic OAuth scope); to use it,
-supply a dedicated token with the `models: read` permission (a fine-grained PAT)
-via `github_models.token`.
+The Device Flow requests the `read:user copilot` scopes.
To authenticate without starting the server (useful for CI/headless setups):
@@ -98,12 +94,7 @@ It walks through:
3. **Model mappings** — fetches the live model catalog and lets you map the
`opus` / `sonnet` / `haiku` aliases to specific models, or keep the
recommended defaults.
-4. **GitHub Models** — optionally enable routing of `publisher/model` ids to the
- [GitHub Models](configuration.md#github-models) inference API. The wizard
- checks whether your GitHub token already grants access and, if not, guides you
- to create a fine-grained PAT with the `models: read` permission, validates the
- token against the catalog, and saves it to `github_models.token`.
-5. **Client setup** — optionally configure Claude Code
+4. **Client setup** — optionally configure Claude Code
(`~/.claude/settings.json`), Codex (`~/.codex/config.toml`), and the Gemini
CLI (`~/.gemini/.env`) to route through the proxy. Existing settings are
preserved and any user-set API key is left untouched.
diff --git a/public/dashboard.html b/public/dashboard.html
index d906c59..a9a7d17 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -86,9 +86,16 @@
Prompt cache
it cannot dominate the page you open to check spend. -->
Supported models
-
+
- | ID | Display name | Owned by |
+
+
+ | ID | Display name | Owned by |
+ Context window |
+ Max output |
+ 1M context |
+
+
@@ -493,9 +500,23 @@
Prompt cache
const data = await (await fetch('/api/models')).json();
const models = (data && data.data) || [];
document.getElementById('modelsSummary').textContent = `Supported models (${models.length})`;
- document.getElementById('modelsBody').innerHTML = models.map(m =>
- `
| ${esc(m.id)} | ${esc(m.display_name)} | ${esc(m.owned_by)} |
`
- ).join('');
+ document.getElementById('modelsBody').innerHTML = models.map(m => {
+ const context = m.context_window == null ? '—' : compact(m.context_window);
+ const output = m.max_output_tokens == null ? '—' : compact(m.max_output_tokens);
+ const extended = m.supports_1m_context == null
+ ? '
—'
+ : m.supports_1m_context
+ ? '
yes'
+ : '
no';
+ return `
+ | ${esc(m.id)} |
+ ${esc(m.display_name)} |
+ ${esc(m.owned_by)} |
+ ${context} |
+ ${output} |
+ ${extended} |
+
`;
+ }).join('');
}
async function refresh() {
diff --git a/src/auth.rs b/src/auth.rs
index 73b0ed9..c9eb1fa 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -1,7 +1,7 @@
//! Authentication: GitHub token acquisition (environment variable, token file,
//! or Device Flow) and Copilot token exchange / refresh.
-use crate::config::{config_dir, GITHUB_API, GITHUB_CLIENT_ID, GITHUB_MODELS_BASE};
+use crate::config::{config_dir, GITHUB_API, GITHUB_CLIENT_ID};
use serde::Deserialize;
use std::path::PathBuf;
use std::time::Duration;
@@ -124,12 +124,6 @@ pub async fn device_flow(client: &reqwest::Client) -> Option
{
.header("Accept", "application/json")
.form(&[
("client_id", GITHUB_CLIENT_ID),
- // Only request scopes the Copilot OAuth app supports. `models` is
- // NOT a valid classic OAuth scope, so requesting it here makes the
- // device-code request fail with `invalid_scope`, blocking all
- // authentication. GitHub Models (https://models.github.ai) instead
- // needs a fine-grained PAT with the `models: read` permission,
- // supplied via `github_models.token` in the config.
("scope", "read:user copilot"),
])
.send()
@@ -249,55 +243,6 @@ pub async fn resolve_github_token(client: &reqwest::Client) -> Option {
Some(token)
}
-/// Checks whether a token can access the GitHub Models inference API by
-/// requesting the model catalog (`GET /catalog/models`) with it. Returns the
-/// number of catalog entries on success, or an error describing the failure
-/// (e.g. an HTTP `401`/`403` when the token lacks the `models: read`
-/// permission). Used by the setup wizard to validate a GitHub Models token
-/// before saving it.
-pub async fn check_models_token_access(
- client: &reqwest::Client,
- token: &str,
- api_version: &str,
-) -> Result {
- let url = format!("{GITHUB_MODELS_BASE}/catalog/models");
- let resp = client
- .get(&url)
- .header("Authorization", format!("Bearer {token}"))
- .header("Accept", "application/vnd.github+json")
- .header("X-GitHub-Api-Version", api_version)
- .header("User-Agent", "ghc-proxy")
- .timeout(Duration::from_secs(15))
- .send()
- .await
- .map_err(|e| format!("request failed: {e}"))?;
- let status = resp.status();
- if !status.is_success() {
- return Err(format!("catalog request returned {status}"));
- }
- let catalog: serde_json::Value = resp
- .json()
- .await
- .map_err(|e| format!("failed to parse catalog: {e}"))?;
- let count = catalog
- .as_array()
- .map(|a| a.len())
- .or_else(|| {
- catalog
- .get("models")
- .and_then(|m| m.as_array())
- .map(|a| a.len())
- })
- .or_else(|| {
- catalog
- .get("data")
- .and_then(|d| d.as_array())
- .map(|a| a.len())
- })
- .unwrap_or(0);
- Ok(count)
-}
-
#[derive(Debug, Deserialize)]
struct CopilotTokenResponse {
token: String,
diff --git a/src/config.rs b/src/config.rs
index 7e510c2..b9bb88d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -21,16 +21,23 @@ pub const COPILOT_VERSION: &str = "0.48.1";
/// Config schema version used to detect when defaults/options changed and a
/// persisted config should be rewritten with migrated values.
///
-/// Bumped to 5 for the Gemini model mappings, so existing files gain the
-/// `gemini-*` aliases the Gemini CLI sends.
-pub const CONFIG_VERSION: u32 = 5;
+/// Bumped to 6 to remove the retired GitHub Models settings from persisted
+/// configuration.
+pub const CONFIG_VERSION: u32 = 6;
-/// Default model name that Claude "opus"/"sonnet" requests are mapped to.
+/// Default model name that Claude "opus" requests are mapped to.
///
/// The catalog carries `claude-opus-4.6` through `claude-opus-5`; this is the
/// newest generally-available one, and it matches 4.8 on every published
/// capability -- 1M context, 64k output, billing multiplier 1, vision.
pub const DEFAULT_OPUS: &str = "claude-opus-5";
+/// Default model name that Claude "sonnet" requests are mapped to.
+///
+/// The catalog carries `claude-sonnet-5` alongside `claude-opus-5`; unlike the
+/// Opus aliases, a Sonnet spelling resolves to the newest Sonnet rather than
+/// being folded into Opus, since a caller that asked for the mid tier expects
+/// its cost and rate limits, not the top one's.
+pub const DEFAULT_SONNET: &str = "claude-sonnet-5";
/// Default model name that Claude "haiku" requests are mapped to.
pub const DEFAULT_HAIKU: &str = "claude-haiku-4.5";
/// Default model name that Gemini requests are mapped to.
@@ -48,15 +55,6 @@ pub const GITHUB_CLIENT_ID: &str = "01ab8ac9400c4e429b23";
/// GitHub REST API base URL.
pub const GITHUB_API: &str = "https://api.github.com";
-/// GitHub Models inference service base URL (https://models.github.ai).
-/// Exposes OpenAI-compatible chat completions, embeddings, and a model catalog.
-pub const GITHUB_MODELS_BASE: &str = "https://models.github.ai";
-
-/// Default `X-GitHub-Api-Version` header value sent to the GitHub Models API.
-/// This is the REST API versioning header, independent of the Copilot
-/// `api_version`, and matches the value in GitHub's Models quickstart.
-pub const GITHUB_MODELS_API_VERSION: &str = "2022-11-28";
-
/// Default listen address.
pub const DEFAULT_ADDRESS: &str = "127.0.0.1";
/// Default listen port.
@@ -71,52 +69,6 @@ pub struct ModelMappings {
pub prefix: BTreeMap,
}
-/// GitHub Models inference settings. When enabled, requests whose (translated)
-/// model id uses the `publisher/model` convention (i.e. it contains a `/`, such
-/// as `openai/gpt-4o`) are routed to the GitHub Models API at
-/// `https://models.github.ai` instead of the Copilot upstream. These ids never
-/// collide with Copilot model ids, which never contain a `/`.
-///
-/// GitHub Models authenticates with a token that carries the `models: read`
-/// permission (a fine-grained PAT). The Device Flow token does not have this
-/// permission, so supply a dedicated token via `github_models.token`.
-#[derive(Debug, Clone, Deserialize)]
-pub struct GithubModels {
- /// Enable routing of `publisher/model` ids to GitHub Models. Default: true.
- #[serde(default = "default_true")]
- pub enabled: bool,
- /// Optional organization to attribute inference to. When set, requests use
- /// `POST /orgs/{org}/inference/chat/completions`.
- #[serde(default)]
- pub org: Option,
- /// Optional dedicated token for GitHub Models (e.g. a fine-grained PAT with
- /// the `models: read` permission). Falls back to the resolved GitHub token
- /// when unset/empty.
- #[serde(default)]
- pub token: Option,
- /// `X-GitHub-Api-Version` header value for GitHub Models requests.
- #[serde(default = "default_models_api_version")]
- pub api_version: String,
-}
-
-impl Default for GithubModels {
- fn default() -> Self {
- GithubModels {
- enabled: true,
- org: None,
- token: None,
- api_version: default_models_api_version(),
- }
- }
-}
-
-fn default_true() -> bool {
- true
-}
-fn default_models_api_version() -> String {
- GITHUB_MODELS_API_VERSION.to_string()
-}
-
/// Parsed representation of `config.yaml`.
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
@@ -138,10 +90,6 @@ pub struct Config {
pub copilot_version: String,
#[serde(default)]
pub model_mappings: ModelMappings,
- /// GitHub Models inference settings (routing of `publisher/model` ids to
- /// `https://models.github.ai`).
- #[serde(default)]
- pub github_models: GithubModels,
#[serde(default)]
pub system_prompt_remove: Vec,
#[serde(default)]
@@ -223,6 +171,9 @@ fn default_loaded_config_version() -> u32 {
fn default_port() -> u16 {
DEFAULT_PORT
}
+fn default_true() -> bool {
+ true
+}
fn default_account_type() -> String {
"individual".to_string()
}
@@ -264,7 +215,6 @@ impl Default for Config {
api_version: default_api_version(),
copilot_version: default_copilot_version(),
model_mappings: default_model_mappings(),
- github_models: GithubModels::default(),
system_prompt_remove: Vec::new(),
system_prompt_add: Vec::new(),
tool_result_suffix_remove: Vec::new(),
@@ -300,48 +250,29 @@ impl Config {
pub fn user_agent(&self) -> String {
format!("GitHubCopilotChat/{}", self.copilot_version)
}
-
- /// Whether a (translated) model id should be routed to the GitHub Models
- /// inference API rather than the Copilot upstream. True when GitHub Models
- /// is enabled and the id uses the `publisher/model` convention (contains a
- /// `/`), which never collides with Copilot model ids.
- pub fn routes_to_github_models(&self, model: &str) -> bool {
- self.github_models.enabled && model.contains('/')
- }
-
- /// GitHub Models chat-completions inference URL, using the org-attributed
- /// path when an organization is configured.
- pub fn github_models_inference_url(&self) -> String {
- match self.github_models.org.as_deref() {
- Some(org) if !org.is_empty() => {
- format!("{GITHUB_MODELS_BASE}/orgs/{org}/inference/chat/completions")
- }
- _ => format!("{GITHUB_MODELS_BASE}/inference/chat/completions"),
- }
- }
-
- /// GitHub Models catalog URL used to list available models.
- pub fn github_models_catalog_url(&self) -> String {
- format!("{GITHUB_MODELS_BASE}/catalog/models")
- }
}
/// Built-in default model mappings (mirrors ghc-tunnel defaults).
pub fn default_model_mappings() -> ModelMappings {
let opus = DEFAULT_OPUS.to_string();
+ let sonnet = DEFAULT_SONNET.to_string();
let haiku = DEFAULT_HAIKU.to_string();
let mut exact = BTreeMap::new();
for k in [
- "opus", "sonnet", "opus4-7", "opus4-8", "opus5", "4-7[1m]", "4-8[1m]", "5[1m]",
+ "opus", "opus4-7", "opus4-8", "opus5", "4-7[1m]", "4-8[1m]", "5[1m]",
] {
exact.insert(k.to_string(), opus.clone());
}
+ exact.insert("sonnet".to_string(), sonnet.clone());
exact.insert("haiku".to_string(), haiku.clone());
- // Every spelling of a Claude model resolves to the current best one. The
- // list is exhaustive rather than pattern-based because a request naming a
- // model that has since been superseded should still be served, and because
- // Anthropic writes the same version two ways (`4.8` and `4-8`).
+ // Every spelling of a Claude model resolves to the current best one in its
+ // own tier. The list is exhaustive rather than pattern-based because a
+ // request naming a model that has since been superseded should still be
+ // served, and because Anthropic writes the same version two ways (`4.8`
+ // and `4-8`). Sonnet spellings resolve to the newest Sonnet rather than
+ // being folded into Opus, so a caller that asked for the mid tier keeps
+ // its cost and rate limits.
//
// Full ids need no `[1m]` spelling of their own: the suffix is stripped
// generically, and a prefix entry matches the suffixed id anyway. Only the
@@ -349,7 +280,6 @@ pub fn default_model_mappings() -> ModelMappings {
// leaves `4-8`, which nothing else maps.
let mut prefix = BTreeMap::new();
for k in [
- "claude-sonnet-4-",
"claude-opus-4.5-",
"claude-opus-4.6-",
"claude-opus-4.7-",
@@ -367,6 +297,11 @@ pub fn default_model_mappings() -> ModelMappings {
"claude-opus-4-6",
"claude-opus-4-7",
"claude-opus-4-8",
+ ] {
+ prefix.insert(k.to_string(), opus.clone());
+ }
+ for k in [
+ "claude-sonnet-4-",
"claude-sonnet-4-7",
"claude-sonnet-4-8",
"claude-sonnet-4-6",
@@ -375,7 +310,7 @@ pub fn default_model_mappings() -> ModelMappings {
"claude-sonnet-5-",
"claude-sonnet-5",
] {
- prefix.insert(k.to_string(), opus.clone());
+ prefix.insert(k.to_string(), sonnet.clone());
}
for k in ["claude-haiku-4.5-", "claude-haiku-4-5-"] {
prefix.insert(k.to_string(), haiku.clone());
@@ -488,31 +423,6 @@ pub fn render_config_yaml(cfg: &Config) -> String {
let _ = writeln!(s, " {}: {}", yaml_scalar(k), yaml_scalar(v));
}
s.push('\n');
- s.push_str("# GitHub Models (https://models.github.ai) inference\n");
- s.push_str("# When enabled, requests whose model id uses the publisher/model form\n");
- s.push_str("# (e.g. openai/gpt-4o) route to GitHub Models instead of Copilot. GitHub\n");
- s.push_str("# Models needs a token with the `models: read` permission (a fine-grained\n");
- s.push_str("# PAT). The Device Flow token lacks it, so set `token` below.\n");
- s.push_str("github_models:\n");
- let _ = writeln!(s, " enabled: {}", cfg.github_models.enabled);
- match cfg.github_models.org.as_deref() {
- Some(org) if !org.is_empty() => {
- let _ = writeln!(s, " org: {}", yaml_scalar(org));
- }
- _ => s.push_str(" # org: my-org # attribute inference to an organization\n"),
- }
- match cfg.github_models.token.as_deref() {
- Some(tok) if !tok.is_empty() => {
- let _ = writeln!(s, " token: {}", yaml_scalar(tok));
- }
- _ => s.push_str(
- " # token: ghp_xxx # dedicated token with the models: read permission\n",
- ),
- }
- if cfg.github_models.api_version != GITHUB_MODELS_API_VERSION {
- let _ = writeln!(s, " api_version: \"{}\"", cfg.github_models.api_version);
- }
- s.push('\n');
s.push_str("# Content Filtering\n");
s.push_str("# system_prompt_remove: strings to strip from system prompts\n");
s.push_str("# system_prompt_add: strings to append to system prompts\n");
@@ -842,24 +752,6 @@ pub fn load_config_with_options(write_back_on_migration: bool) -> Config {
tracing::info!("API key auth enabled via GHC_PROXY_API_KEY");
}
- if let Ok(val) = std::env::var("GHC_PROXY_GITHUB_MODELS_ENABLED") {
- cfg.github_models.enabled = val.eq_ignore_ascii_case("true") || val == "1";
- tracing::info!(
- "✓ Overriding github_models.enabled from GHC_PROXY_GITHUB_MODELS_ENABLED: {}",
- cfg.github_models.enabled
- );
- }
- if let Ok(val) = std::env::var("GHC_PROXY_GITHUB_MODELS_ORG") {
- let trimmed = val.trim();
- cfg.github_models.org = (!trimmed.is_empty()).then(|| trimmed.to_string());
- tracing::info!("✓ Overriding github_models.org from GHC_PROXY_GITHUB_MODELS_ORG");
- }
- if let Ok(val) = std::env::var("GHC_PROXY_GITHUB_MODELS_TOKEN") {
- let trimmed = val.trim();
- cfg.github_models.token = (!trimmed.is_empty()).then(|| trimmed.to_string());
- tracing::info!("GitHub Models token set via GHC_PROXY_GITHUB_MODELS_TOKEN");
- }
-
cfg
}
@@ -921,25 +813,25 @@ fn migrate_config(cfg: &mut Config) -> bool {
// rewrite. Existing mappings keep pointing where they were told to;
// `--setup` or `--default` is how a user asks for the new defaults.
let opus = DEFAULT_OPUS.to_string();
+ let sonnet = DEFAULT_SONNET.to_string();
for k in ["opus5", "5[1m]"] {
cfg.model_mappings
.exact
.entry(k.to_string())
.or_insert_with(|| opus.clone());
}
- for k in [
- "claude-opus-5-",
- "claude-opus-5",
- "claude-opus-5[1m]",
- "claude-sonnet-5-",
- "claude-sonnet-5",
- "claude-sonnet-4.6",
- ] {
+ for k in ["claude-opus-5-", "claude-opus-5", "claude-opus-5[1m]"] {
cfg.model_mappings
.prefix
.entry(k.to_string())
.or_insert_with(|| opus.clone());
}
+ for k in ["claude-sonnet-5-", "claude-sonnet-5", "claude-sonnet-4.6"] {
+ cfg.model_mappings
+ .prefix
+ .entry(k.to_string())
+ .or_insert_with(|| sonnet.clone());
+ }
}
if cfg.config_version < 5 {
@@ -1025,112 +917,12 @@ mod tests {
assert_eq!(cfg.upstream_read_timeout_seconds, 900);
}
- #[test]
- fn github_models_defaults_enabled() {
- let gm = GithubModels::default();
- assert!(gm.enabled);
- assert!(gm.org.is_none());
- assert!(gm.token.is_none());
- assert_eq!(gm.api_version, GITHUB_MODELS_API_VERSION);
- // The default Config carries the same enabled-by-default value.
- assert!(Config::default().github_models.enabled);
- }
-
- #[test]
- fn routes_publisher_model_ids_to_github_models() {
- let cfg = Config::default();
- // `publisher/model` ids route to GitHub Models...
- assert!(cfg.routes_to_github_models("openai/gpt-4o"));
- assert!(cfg.routes_to_github_models("meta/llama-4-maverick"));
- // ...while plain Copilot ids never do (they contain no slash).
- assert!(!cfg.routes_to_github_models("claude-opus-4.8"));
- assert!(!cfg.routes_to_github_models("gpt-4o"));
- assert!(!cfg.routes_to_github_models("gemini-2.5-pro"));
- }
-
- #[test]
- fn disabled_github_models_never_routes() {
- let mut cfg = Config::default();
- cfg.github_models.enabled = false;
- assert!(!cfg.routes_to_github_models("openai/gpt-4o"));
- }
-
- #[test]
- fn inference_url_uses_org_when_set() {
- let mut cfg = Config::default();
- assert_eq!(
- cfg.github_models_inference_url(),
- "https://models.github.ai/inference/chat/completions"
- );
- cfg.github_models.org = Some("my-org".to_string());
- assert_eq!(
- cfg.github_models_inference_url(),
- "https://models.github.ai/orgs/my-org/inference/chat/completions"
- );
- // An empty org string falls back to the non-attributed endpoint.
- cfg.github_models.org = Some(String::new());
- assert_eq!(
- cfg.github_models_inference_url(),
- "https://models.github.ai/inference/chat/completions"
- );
- }
-
- #[test]
- fn catalog_url_is_stable() {
- assert_eq!(
- Config::default().github_models_catalog_url(),
- "https://models.github.ai/catalog/models"
- );
- }
-
- #[test]
- fn rendered_config_roundtrips_github_models() {
- // Rendered YAML must parse back into an equivalent config.
- let mut cfg = Config::default();
- cfg.github_models.org = Some("acme".to_string());
- let yaml = render_config_yaml(&cfg);
- let parsed: Config = serde_norway::from_str(&yaml).expect("render must re-parse");
- assert!(parsed.github_models.enabled);
- assert_eq!(parsed.github_models.org.as_deref(), Some("acme"));
- assert_eq!(parsed.github_models.api_version, GITHUB_MODELS_API_VERSION);
- }
-
- #[test]
- fn absent_github_models_key_defaults_enabled() {
- // Legacy config files predating this feature omit the key entirely.
- let yaml = "config_version: 2\naddress: 127.0.0.1\nport: 8314\n";
- let parsed: Config = serde_norway::from_str(yaml).expect("legacy config parses");
- assert!(parsed.github_models.enabled);
- }
-
#[test]
fn default_rendered_config_reparses() {
// The default document is written on first run and on corruption rebuild;
- // it must always re-parse. In the default case org/token render as
- // comments and api_version is omitted, so the block is just `enabled`.
+ // it must always re-parse.
let yaml = default_config_yaml();
- let parsed: Config = serde_norway::from_str(&yaml).expect("default config re-parses");
- assert!(parsed.github_models.enabled);
- assert!(parsed.github_models.org.is_none());
- assert!(parsed.github_models.token.is_none());
- assert_eq!(parsed.github_models.api_version, GITHUB_MODELS_API_VERSION);
- // api_version is only emitted when non-default, so the default value
- // never appears in the rendered document.
- assert!(!yaml.contains(GITHUB_MODELS_API_VERSION));
- }
-
- #[test]
- fn rendered_token_and_custom_api_version_roundtrip() {
- // Exercise the token (yaml_scalar) and non-default api_version branches
- // that the org-only case leaves untested.
- let mut cfg = Config::default();
- cfg.github_models.token = Some("ghp_abc123".to_string());
- cfg.github_models.api_version = "2099-01-01".to_string();
- let yaml = render_config_yaml(&cfg);
- assert!(yaml.contains("api_version: \"2099-01-01\""));
- let parsed: Config = serde_norway::from_str(&yaml).expect("render must re-parse");
- assert_eq!(parsed.github_models.token.as_deref(), Some("ghp_abc123"));
- assert_eq!(parsed.github_models.api_version, "2099-01-01");
+ serde_norway::from_str::(&yaml).expect("default config re-parses");
}
#[test]
@@ -1146,13 +938,12 @@ mod tests {
assert_eq!(cfg.address, "0.0.0.0");
assert_eq!(cfg.port, 9000);
// ...while properties the old file never had take their defaults.
- assert!(cfg.github_models.enabled);
assert_eq!(cfg.max_connection_retries, default_max_retries());
assert_eq!(cfg.api_version, API_VERSION);
// The re-rendered document carries them forward.
let rendered = render_config_yaml(&cfg);
assert!(rendered.contains(&format!("config_version: {CONFIG_VERSION}")));
- assert!(rendered.contains("github_models:"));
+ assert!(!rendered.contains("github_models:"));
}
#[test]
@@ -1240,7 +1031,7 @@ mod tests {
.prefix
.get("claude-sonnet-5")
.map(String::as_str),
- Some(DEFAULT_OPUS)
+ Some(DEFAULT_SONNET)
);
}
diff --git a/src/main.rs b/src/main.rs
index dab0440..1fa1015 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -148,9 +148,6 @@ Environment Variables:
GHC_PROXY_RATE_LIMIT_SECONDS Minimum seconds between requests
GHC_PROXY_RATE_LIMIT_WAIT Wait instead of rejecting when limited (true/1)
GHC_PROXY_MANUAL_APPROVE Require manual approval per request (true/1)
- GHC_PROXY_GITHUB_MODELS_ENABLED Route publisher/model ids to GitHub Models (true/1)
- GHC_PROXY_GITHUB_MODELS_ORG Attribute GitHub Models inference to an org
- GHC_PROXY_GITHUB_MODELS_TOKEN Dedicated token for GitHub Models (models:read permission)
Priority: CLI flags > Environment variables > Config file > Defaults",
port = config::DEFAULT_PORT,
diff --git a/src/server.rs b/src/server.rs
index 0640de8..dff4823 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -326,10 +326,7 @@ fn extract_message_count(body: &Value) -> usize {
/// `gpt-4`, `opus`/`sonnet`/`haiku` before the generic `claude` fallback).
fn model_rates(model: &str) -> (f64, f64) {
let m = model.to_ascii_lowercase();
- // Strip a `publisher/` prefix so GitHub Models ids price like their base
- // model (e.g. `openai/gpt-4o` -> `gpt-4o`).
- let m = m.rsplit('/').next().unwrap_or(&m);
- match m {
+ match m.as_str() {
m if m.contains("opus") => (0.015, 0.075),
m if m.contains("sonnet") => (0.003, 0.015),
m if m.contains("haiku") => (0.0008, 0.004),
@@ -1017,13 +1014,8 @@ async fn chat_completions(State(state): State, body: Bytes) -> Resp
req["model"] = Value::String(translated.clone());
}
- // GitHub Models requests use the raw GitHub token, not the Copilot token, so
- // only ensure the Copilot token when the request routes to Copilot.
- let to_github_models = state.config_snapshot().routes_to_github_models(&translated);
- if !to_github_models {
- if let Err(e) = state.ensure_copilot_token().await {
- return error_response(StatusCode::INTERNAL_SERVER_ERROR, e);
- }
+ if let Err(e) = state.ensure_copilot_token().await {
+ return error_response(StatusCode::INTERNAL_SERVER_ERROR, e);
}
if let Err(e) = state.apply_request_gate("/v1/chat/completions").await {
return error_response(StatusCode::TOO_MANY_REQUESTS, e);
@@ -1032,10 +1024,9 @@ async fn chat_completions(State(state): State, body: Bytes) -> Resp
// Some Copilot models are only reachable through `/responses` and answer a
// chat-completions call with an opaque `unsupported_api_for_model` 400.
// Turn that into an actionable message before spending the round trip.
- if !to_github_models
- && !state
- .model_supports_endpoint(&translated, "/chat/completions")
- .await
+ if !state
+ .model_supports_endpoint(&translated, "/chat/completions")
+ .await
&& state
.model_supports_endpoint(&translated, "/responses")
.await
@@ -1087,19 +1078,11 @@ async fn chat_completions(State(state): State, body: Bytes) -> Resp
)
});
- let (url, mut headers, is_github_models) = state.chat_upstream(&translated, vision).await;
- if !is_github_models {
- set_initiator(&mut headers, agent);
- }
+ let (url, mut headers) = state.chat_upstream(vision).await;
+ set_initiator(&mut headers, agent);
let req_size = body.len();
let is_stream = req.get("stream").and_then(|s| s.as_bool()).unwrap_or(false);
- // GitHub Models (strict OpenAI-compatible) only emits a final usage chunk on
- // streaming requests when asked. Copilot emits it unconditionally, so only
- // opt in for GitHub Models and only when the client hasn't set its own.
- if is_github_models && is_stream && req.get("stream_options").is_none() {
- req["stream_options"] = json!({"include_usage": true});
- }
let payload = serde_json::to_vec(&req).unwrap_or_default();
log_debug_request(&state, "/v1/chat/completions", &req);
@@ -1230,17 +1213,6 @@ async fn responses(State(state): State, body: Bytes) -> Response {
req["model"] = Value::String(translated.clone());
}
- // /v1/responses is the Codex Responses API — Copilot-only.
- // GitHub Models models (publisher/model convention) are not supported here.
- if state.config_snapshot().routes_to_github_models(&translated) {
- return error_response(
- StatusCode::BAD_REQUEST,
- format!(
- "Model '{original_model}' routes to GitHub Models which does not support \
- the Responses API. Use /v1/chat/completions with '{translated}' instead."
- ),
- );
- }
if !state
.model_supports_endpoint(&translated, "/responses")
.await
@@ -1835,9 +1807,6 @@ async fn messages(
req["model"] = Value::String(translated.clone());
}
- // /v1/messages is the Anthropic Messages API used by Claude Code.
- // GitHub Models only exposes an OpenAI-compatible chat-completions surface,
- // so we never route this endpoint there — always use Copilot.
if let Err(e) = state.ensure_copilot_token().await {
return anthropic_error(StatusCode::INTERNAL_SERVER_ERROR, e);
}
@@ -2186,8 +2155,6 @@ async fn messages_translated(
})
})
.unwrap_or(false);
- // /v1/messages always targets Copilot; GitHub Models routing is handled
- // at the /v1/chat/completions level only.
let url = format!("{}/chat/completions", state.copilot_base_url());
let mut headers = state.copilot_headers(vision).await;
set_initiator(&mut headers, agent);
@@ -2456,13 +2423,8 @@ async fn gemini_generate(
let is_stream = action == "streamGenerateContent" || action == "streamgeneratecontent";
- // GitHub Models uses the raw GitHub token; only ensure the Copilot token
- // when the request routes to Copilot.
- let to_github_models = state.config_snapshot().routes_to_github_models(&translated);
- if !to_github_models {
- if let Err(e) = state.ensure_copilot_token().await {
- return gemini_error(StatusCode::INTERNAL_SERVER_ERROR, e);
- }
+ if let Err(e) = state.ensure_copilot_token().await {
+ return gemini_error(StatusCode::INTERNAL_SERVER_ERROR, e);
}
if let Err(e) = state.apply_request_gate("/v1beta/models").await {
return gemini_error(StatusCode::TOO_MANY_REQUESTS, e);
@@ -2471,10 +2433,8 @@ async fn gemini_generate(
let openai_req = gemini::gemini_to_openai(&req, &translated, is_stream);
let vision = gemini::has_image(&req);
let agent = gemini::is_agent(&req);
- let (url, mut headers, is_github_models) = state.chat_upstream(&translated, vision).await;
- if !is_github_models {
- set_initiator(&mut headers, agent);
- }
+ let (url, mut headers) = state.chat_upstream(vision).await;
+ set_initiator(&mut headers, agent);
let req_size = body.len();
let payload = serde_json::to_vec(&openai_req).unwrap_or_default();
@@ -3001,9 +2961,8 @@ async fn stream_openai(
};
state.record_quota_headers(upstream.headers());
let status = upstream.status().as_u16();
- // A non-2xx upstream (e.g. GitHub Models returning 401/403 as JSON when
- // the token lacks the `models: read` permission) is not an SSE stream —
- // surface it as a normal error instead of forwarding a broken "stream".
+ // A non-2xx upstream is not an SSE stream; surface it as a normal error
+ // instead of forwarding a broken stream.
if !is_streamable_status(status) {
let text = upstream.text().await.unwrap_or_default();
log_debug_response(&state, endpoint, &text);
@@ -4639,8 +4598,6 @@ mod tests {
assert_eq!(model_rates("gpt-4o"), (0.005, 0.015));
assert_eq!(model_rates("gpt-4-turbo"), (0.03, 0.06));
assert_eq!(model_rates("gpt-4o-mini"), (0.00015, 0.0006));
- // Publisher-qualified GitHub Models ids price like their base model.
- assert_eq!(model_rates("openai/gpt-4o"), model_rates("gpt-4o"));
// Claude tiers are distinct.
assert_eq!(model_rates("claude-opus-4.8"), (0.015, 0.075));
assert_eq!(model_rates("claude-haiku-4.5"), (0.0008, 0.004));
diff --git a/src/setup.rs b/src/setup.rs
index 6025484..66f9e08 100644
--- a/src/setup.rs
+++ b/src/setup.rs
@@ -11,7 +11,7 @@ use std::io::IsTerminal;
use std::sync::Arc;
use dialoguer::theme::ColorfulTheme;
-use dialoguer::{Confirm, FuzzySelect, Input, Password, Select};
+use dialoguer::{Confirm, FuzzySelect, Input, Select};
use ghc_proxy::config::{self, Config, ModelMappings};
use ghc_proxy::{auth, state::AppState};
@@ -101,10 +101,7 @@ pub async fn run(starting: Config, claudecode_flag: bool) -> Option {
}
}
- // --- Step 5: GitHub Models token -------------------------------------
- configure_github_models(&mut cfg, &token, &client).await;
-
- // --- Step 6: Claude Code ---------------------------------------------
+ // --- Step 5: Claude Code ---------------------------------------------
let configure_claude_code = if claudecode_flag {
true
} else {
@@ -224,158 +221,6 @@ fn prompt_claude_code() -> dialoguer::Result {
.interact()
}
-/// Interactive GitHub Models step. Confirms whether to route `publisher/model`
-/// ids to the GitHub Models inference API and, when enabled, ensures a working
-/// token with the `models: read` permission is available.
-///
-/// GitHub Models cannot be authorized through the Device Flow (`models` is not a
-/// valid classic OAuth scope), so this step first checks whether the already
-/// resolved GitHub token happens to grant access; if not, it guides the user to
-/// create a fine-grained PAT, validates the pasted token against the catalog,
-/// and stores it in the configuration.
-async fn configure_github_models(cfg: &mut Config, github_token: &str, client: &reqwest::Client) {
- section("GitHub Models");
- println!(
- "GitHub Models (https://models.github.ai) serves `publisher/model` ids\n\
- (e.g. openai/gpt-4o) from a service separate from Copilot."
- );
-
- let default_enabled = cfg.github_models.enabled;
- let enable = matches!(
- tokio::task::spawn_blocking(move || prompt_enable_models(default_enabled)).await,
- Ok(Ok(true))
- );
- cfg.github_models.enabled = enable;
- if !enable {
- println!("GitHub Models routing disabled.");
- return;
- }
-
- let api_version = cfg.github_models.api_version.clone();
-
- // 1. If a dedicated token is already configured, validate and keep it.
- if let Some(existing) = cfg
- .github_models
- .token
- .as_deref()
- .filter(|t| !t.is_empty())
- .map(str::to_string)
- {
- match auth::check_models_token_access(client, &existing, &api_version).await {
- Ok(n) => {
- println!("✓ Configured GitHub Models token works ({n} models available).");
- prompt_and_set_org(cfg).await;
- return;
- }
- Err(e) => {
- println!("⚠ The configured GitHub Models token no longer works ({e}).");
- }
- }
- }
-
- // 2. Otherwise, see if the resolved GitHub token already has models access.
- match auth::check_models_token_access(client, github_token, &api_version).await {
- Ok(n) => {
- println!(
- "✓ Your GitHub sign-in token already has GitHub Models access \
- ({n} models available)."
- );
- println!(" No separate token is needed.");
- cfg.github_models.token = None;
- prompt_and_set_org(cfg).await;
- return;
- }
- Err(_) => {
- println!(
- "\nYour GitHub sign-in token does not grant GitHub Models access.\n\
- GitHub Models needs a fine-grained personal access token with the\n\
- \"Models\" account permission set to Read-only.\n\n\
- Create one here (Account permissions → Models → Read-only):\n\
- \x20 https://github.com/settings/personal-access-tokens/new\n"
- );
- }
- }
-
- // 3. Guided capture + validation of a dedicated token.
- loop {
- let entered = match tokio::task::spawn_blocking(prompt_models_token).await {
- Ok(Ok(t)) => t.trim().to_string(),
- _ => String::new(),
- };
- if entered.is_empty() {
- println!(
- "⚠ Skipping GitHub Models token. Requests for publisher/model ids will\n\
- \x20 fail until you set `github_models.token` in the config."
- );
- break;
- }
- match auth::check_models_token_access(client, &entered, &api_version).await {
- Ok(n) => {
- cfg.github_models.token = Some(entered);
- println!("✓ Token accepted ({n} models available).");
- break;
- }
- Err(e) => {
- println!("✗ That token could not access GitHub Models ({e}).");
- let retry = matches!(
- tokio::task::spawn_blocking(prompt_retry).await,
- Ok(Ok(true))
- );
- if !retry {
- println!("⚠ Continuing without a GitHub Models token.");
- break;
- }
- }
- }
- }
-
- prompt_and_set_org(cfg).await;
-}
-
-/// Prompts for an optional organization to attribute GitHub Models inference to
-/// and records it on the config. An empty answer clears any existing value.
-async fn prompt_and_set_org(cfg: &mut Config) {
- let current = cfg.github_models.org.clone().unwrap_or_default();
- let org = match tokio::task::spawn_blocking(move || prompt_models_org(current)).await {
- Ok(Ok(o)) => o.trim().to_string(),
- _ => return,
- };
- cfg.github_models.org = (!org.is_empty()).then_some(org);
-}
-
-fn prompt_enable_models(default: bool) -> dialoguer::Result {
- let theme = ColorfulTheme::default();
- Confirm::with_theme(&theme)
- .with_prompt("Enable GitHub Models routing?")
- .default(default)
- .interact()
-}
-
-fn prompt_models_token() -> dialoguer::Result {
- let theme = ColorfulTheme::default();
- Password::with_theme(&theme)
- .with_prompt("Paste a GitHub Models token (models: read), or leave blank to skip")
- .allow_empty_password(true)
- .interact()
-}
-
-fn prompt_retry() -> dialoguer::Result {
- let theme = ColorfulTheme::default();
- Confirm::with_theme(&theme)
- .with_prompt("Try a different token?")
- .default(true)
- .interact()
-}
-
-fn prompt_models_org(default: String) -> dialoguer::Result {
- let theme = ColorfulTheme::default();
- Input::with_theme(&theme)
- .with_prompt("Attribute inference to an organization (optional, blank for none)")
- .default(default)
- .allow_empty(true)
- .interact_text()
-}
-
/// Resolves a Copilot token and fetches the available model ids, returning a
/// sorted, de-duplicated list. Returns an empty vector on any failure.
async fn fetch_model_ids(cfg: &Config, token: &str) -> Vec {
diff --git a/src/state.rs b/src/state.rs
index 78f04e9..9ac1ebf 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -439,55 +439,10 @@ impl AppState {
h
}
- /// Token used for GitHub Models requests: the dedicated `github_models.token`
- /// when configured, otherwise the resolved GitHub token. This token must
- /// carry the `models: read` permission (a fine-grained PAT); the Device Flow
- /// token does not have it.
- pub async fn github_models_token(&self) -> String {
- if let Some(token) = self.config_snapshot().github_models.token {
- if !token.is_empty() {
- return token;
- }
- }
- self.tokens.lock().await.github_token.clone()
- }
-
- /// Headers for a GitHub Models inference request. Unlike the Copilot path,
- /// this authenticates with the raw GitHub token via `Authorization: Bearer`
- /// and sends the standard GitHub REST API headers. None of the Copilot
- /// impersonation headers are included.
- pub async fn github_models_headers(&self) -> HeaderMap {
- let cfg = self.config_snapshot();
- let token = self.github_models_token().await;
- let mut h = HeaderMap::new();
- insert(&mut h, "Authorization", &format!("Bearer {token}"));
- h.insert("Content-Type", HeaderValue::from_static("application/json"));
- h.insert(
- "Accept",
- HeaderValue::from_static("application/vnd.github+json"),
- );
- insert(
- &mut h,
- "X-GitHub-Api-Version",
- &cfg.github_models.api_version,
- );
- insert(&mut h, "User-Agent", &cfg.user_agent());
- h
- }
-
- /// Resolves the upstream chat-completions `(url, headers, is_github_models)`
- /// for a given (translated) model. Routes to GitHub Models inference when
- /// the model uses the `publisher/model` convention and GitHub Models is
- /// enabled; otherwise uses the Copilot upstream. The `vision` flag only
- /// affects the Copilot headers.
- pub async fn chat_upstream(&self, model: &str, vision: bool) -> (String, HeaderMap, bool) {
- if self.config_snapshot().routes_to_github_models(model) {
- let url = self.config_snapshot().github_models_inference_url();
- (url, self.github_models_headers().await, true)
- } else {
- let url = format!("{}/chat/completions", self.copilot_base_url());
- (url, self.copilot_headers(vision).await, false)
- }
+ /// Resolves the Copilot chat-completions URL and headers.
+ pub async fn chat_upstream(&self, vision: bool) -> (String, HeaderMap) {
+ let url = format!("{}/chat/completions", self.copilot_base_url());
+ (url, self.copilot_headers(vision).await)
}
/// Upstream URL and headers for the catalog's `ws:/responses` surface.
@@ -541,9 +496,7 @@ impl AppState {
Ok(())
}
- /// Fetches the list of available models from upstream and caches it. When
- /// GitHub Models is enabled, its catalog is appended (best-effort) so those
- /// models also appear in `/v1/models` and the dashboard.
+ /// Fetches the list of available models from upstream and caches it.
pub async fn load_models(&self) -> Result<(), String> {
self.ensure_copilot_token().await?;
let url = format!("{}/models", self.copilot_base_url());
@@ -558,80 +511,19 @@ impl AppState {
if !resp.status().is_success() {
return Err(format!("Failed to fetch models: {}", resp.status()));
}
- let mut json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
- let mut count = json
+ let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
+ let count = json
.get("data")
.and_then(|d| d.as_array())
.map(|a| a.len())
.unwrap_or(0);
- if self.config_snapshot().github_models.enabled {
- match self.load_github_models_catalog().await {
- Ok(entries) => {
- let added = entries.len();
- if let Some(data) = json.get_mut("data").and_then(|d| d.as_array_mut()) {
- data.extend(entries);
- count += added;
- }
- tracing::info!("Loaded {added} GitHub Models catalog entries");
- }
- Err(e) => tracing::warn!("GitHub Models catalog unavailable: {e}"),
- }
- }
-
*self.models.write().await = Some(json);
*self.models_loaded_at.lock().await = Some(Instant::now());
tracing::info!("Loaded {count} models");
Ok(())
}
- /// Fetches the GitHub Models catalog (`GET /catalog/models`) and normalizes
- /// each entry into the model-list shape used by `/v1/models`
- /// (`id` / `name` / `vendor`). Returns an error the caller can log without
- /// failing the primary Copilot model load.
- async fn load_github_models_catalog(&self) -> Result, String> {
- let url = self.config_snapshot().github_models_catalog_url();
- let headers = self.github_models_headers().await;
- let resp = self
- .http
- .get(&url)
- .headers(headers)
- .timeout(Duration::from_secs(15))
- .send()
- .await
- .map_err(|e| e.to_string())?;
- if !resp.status().is_success() {
- return Err(format!("catalog fetch returned {}", resp.status()));
- }
- let catalog: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
- // The catalog is documented as a bare array; tolerate `{models|data:[…]}`
- // wrappers as well.
- let arr = catalog
- .as_array()
- .cloned()
- .or_else(|| catalog.get("models").and_then(|m| m.as_array()).cloned())
- .or_else(|| catalog.get("data").and_then(|m| m.as_array()).cloned())
- .unwrap_or_default();
- let entries = arr
- .iter()
- .filter_map(|m| {
- let id = m.get("id").and_then(|i| i.as_str())?;
- let name = m.get("name").and_then(|n| n.as_str()).unwrap_or(id);
- let vendor = m
- .get("publisher")
- .and_then(|p| p.as_str())
- .unwrap_or("github-models");
- Some(serde_json::json!({
- "id": id,
- "name": name,
- "vendor": vendor,
- "source": "github-models",
- }))
- })
- .collect();
- Ok(entries)
- }
-
pub async fn ensure_models_fresh(&self, max_age: Duration) -> Result<(), String> {
let needs_refresh = {
if self.models.read().await.is_none() {
diff --git a/src/translate.rs b/src/translate.rs
index 5c9cafd..a323863 100644
--- a/src/translate.rs
+++ b/src/translate.rs
@@ -58,20 +58,21 @@ mod tests {
use super::*;
use crate::config::{
default_model_mappings, DEFAULT_GEMINI_FLASH, DEFAULT_GEMINI_PRO, DEFAULT_HAIKU,
- DEFAULT_OPUS,
+ DEFAULT_OPUS, DEFAULT_SONNET,
};
#[test]
fn exact_mapping_wins() {
let m = default_model_mappings();
assert_eq!(translate(&m, "opus"), DEFAULT_OPUS);
+ assert_eq!(translate(&m, "sonnet"), DEFAULT_SONNET);
assert_eq!(translate(&m, "haiku"), DEFAULT_HAIKU);
}
#[test]
fn prefix_mapping_applies() {
let m = default_model_mappings();
- assert_eq!(translate(&m, "claude-sonnet-4-20250101"), DEFAULT_OPUS);
+ assert_eq!(translate(&m, "claude-sonnet-4-20250101"), DEFAULT_SONNET);
assert_eq!(translate(&m, "claude-haiku-4.5-20250101"), DEFAULT_HAIKU);
}
@@ -131,7 +132,7 @@ mod tests {
fn any_model_accepts_the_1m_suffix() {
let m = default_model_mappings();
assert_eq!(translate(&m, "claude-haiku-4.5[1m]"), DEFAULT_HAIKU);
- assert_eq!(translate(&m, "sonnet[1m]"), DEFAULT_OPUS);
+ assert_eq!(translate(&m, "sonnet[1m]"), DEFAULT_SONNET);
assert_eq!(translate(&m, "gpt-4o[1m]"), "gpt-4o");
}