From 2c4ac7f758950fe74c22542042fcba8f2b0d5079 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:53:05 +0000 Subject: [PATCH 1/6] Remove sunset GitHub Models integration Co-authored-by: MartinForReal <5207478+MartinForReal@users.noreply.github.com> --- CHANGELOG.md | 5 + README.md | 63 +---------- docs/api.md | 5 - docs/configuration.md | 33 +----- docs/getting-started.md | 13 +-- src/auth.rs | 57 +--------- src/config.rs | 239 +--------------------------------------- src/main.rs | 3 - src/server.rs | 71 +++--------- src/setup.rs | 159 +------------------------- src/state.rs | 122 ++------------------ 11 files changed, 40 insertions(+), 730 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f7374..6888040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### 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..324fea0 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 @@ -196,10 +187,6 @@ model_mappings: 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) 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 | 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/configuration.md b/docs/configuration.md index c9eb8cc..03f5462 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 @@ -48,14 +48,6 @@ model_mappings: 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 - # Content filtering system_prompt_remove: [] system_prompt_add: [] @@ -105,26 +97,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 @@ -208,9 +180,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/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..24a3911 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,9 +21,9 @@ 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. /// @@ -48,15 +48,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 +62,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 +83,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)] @@ -264,7 +205,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(), @@ -301,29 +241,6 @@ impl Config { 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). @@ -488,31 +405,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 +734,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 } @@ -1025,112 +899,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 +920,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] 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() { From 69979eee09ad3ab3de2f8682ac723c2310f5fb62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:56:29 +0000 Subject: [PATCH 2/6] Restore shared config default helper Co-authored-by: MartinForReal <5207478+MartinForReal@users.noreply.github.com> --- src/config.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 24a3911..29a2b7f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -164,6 +164,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() } @@ -240,7 +243,6 @@ impl Config { pub fn user_agent(&self) -> String { format!("GitHubCopilotChat/{}", self.copilot_version) } - } /// Built-in default model mappings (mirrors ghc-tunnel defaults). From bd353502e935546090d223a6eabfa0f503cba7dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:07:33 +0000 Subject: [PATCH 3/6] docs: sync README/config docs with current codebase state Co-authored-by: MartinForReal <5207478+MartinForReal@users.noreply.github.com> --- README.md | 7 +++++++ docs/configuration.md | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 324fea0..99309f8 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,9 @@ upstream_read_timeout_seconds: 900 # max silence from upstream; 0 disables | `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 | @@ -223,6 +226,9 @@ upstream_read_timeout_seconds: 900 # max silence from upstream; 0 disables | `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; @@ -276,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/configuration.md b/docs/configuration.md index 03f5462..3c1ea58 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -43,7 +43,7 @@ auto_upgrade: true model_mappings: exact: opus: claude-opus-5 - sonnet: claude-sonnet-5 + sonnet: claude-opus-5 haiku: claude-haiku-4.5 prefix: claude-sonnet-4-: claude-opus-5 @@ -119,6 +119,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). From 57a897bbbbdaf7f24bd3dbdfbb90d316ff28112a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:49:59 +0000 Subject: [PATCH 4/6] fix: map sonnet aliases to a Sonnet model instead of Opus Co-authored-by: MartinForReal <5207478+MartinForReal@users.noreply.github.com> --- CHANGELOG.md | 8 +++++++ README.md | 4 ++-- docs/claude-code.md | 7 +++--- docs/configuration.md | 9 ++++---- src/config.rs | 50 ++++++++++++++++++++++++++++--------------- src/translate.rs | 7 +++--- 6 files changed, 56 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6888040..05cd801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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 diff --git a/README.md b/README.md index 99309f8..6d0c08a 100644 --- a/README.md +++ b/README.md @@ -183,10 +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 + claude-sonnet-4-: claude-sonnet-5 system_prompt_remove: [] system_prompt_add: [] tool_result_suffix_remove: [] 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 3c1ea58..a18c9a0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -43,10 +43,10 @@ auto_upgrade: true 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 + claude-sonnet-4-: claude-sonnet-5 # Content filtering system_prompt_remove: [] @@ -77,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. diff --git a/src/config.rs b/src/config.rs index 29a2b7f..b9bb88d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,12 +25,19 @@ pub const COPILOT_VERSION: &str = "0.48.1"; /// 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. @@ -248,19 +255,24 @@ impl Config { /// 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 @@ -268,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-", @@ -286,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", @@ -294,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()); @@ -797,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 { @@ -1015,7 +1031,7 @@ mod tests { .prefix .get("claude-sonnet-5") .map(String::as_str), - Some(DEFAULT_OPUS) + Some(DEFAULT_SONNET) ); } 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"); } From 31c800e680628830a83475c966fc9038faa7fc20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:20:19 +0000 Subject: [PATCH 5/6] feat: show model context limits in dashboard Co-authored-by: MartinForReal <5207478+MartinForReal@users.noreply.github.com> --- public/dashboard.html | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/public/dashboard.html b/public/dashboard.html index d906c59..d5eaf4d 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 -
+
- + + + + + + + +
IDDisplay nameOwned by
IDDisplay nameOwned byContext windowMax output1M context
@@ -493,9 +500,21 @@

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 + ? 'yes' + : 'no'; + return ` + ${esc(m.id)} + ${esc(m.display_name)} + ${esc(m.owned_by)} + ${context} + ${output} + ${extended} + `; + }).join(''); } async function refresh() { From 301d3f9a6e6ef5e4e9573248799ebc2fbbb33368 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:23:29 +0000 Subject: [PATCH 6/6] fix: distinguish unavailable model limit metadata Co-authored-by: MartinForReal <5207478+MartinForReal@users.noreply.github.com> --- public/dashboard.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/public/dashboard.html b/public/dashboard.html index d5eaf4d..a9a7d17 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -503,9 +503,11 @@

Prompt cache

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 - ? 'yes' - : 'no'; + const extended = m.supports_1m_context == null + ? '' + : m.supports_1m_context + ? 'yes' + : 'no'; return ` ${esc(m.id)} ${esc(m.display_name)}