Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,59 @@ target.

## [Unreleased]

### Read before upgrading

- **Model listings follow the calling key.** `GET /v1/models` and
`GET /v1beta/models` listed every routed model whatever the key; they
now list only the models a request with that key is let through for
(see Fixed). A client that lists models with a narrowed key sees fewer
of them, and a key whose allow-list and owner's roles have nothing in
common lists none. No setting, schema, Helm value or Redis key changes.

### Added

- **"Add to ThinkWatch Lite" beside a key shown once.** The new-key and
rotated-key dialogs and the end of first-run setup have an Add to
ThinkWatch Lite button next to Copy. It opens a `thinkwatch://import`
link that ThinkWatch Lite (2026.10.16 or later) turns into a new upstream
for this gateway with the key filled in. The link is built in the browser
from the key on screen and the gateway's public address, and is sent
nowhere. The button is unavailable, with the reason beside it, for a key
without the `ai_gateway` surface and for a gateway whose public address
Lite does not accept. Lite takes `https://`, and plain `http://` only on
this machine or a private network: private IPv4 ranges (`10/8`,
`172.16/12`, `192.168/16`, `100.64/10`, `169.254/16`), private IPv6
ranges (`fc00::/7`, `fe80::/10`), and names with no dot or ending in
`.local`, `.lan`, `.internal`, `.intranet`, `.corp`, `.home.arpa` or
`.localhost`. Elsewhere, set `general.public_protocol` to `https` where
the gateway is served over TLS. Once clicked, a line under it links to
the download page. Phones get Copy only.
- **The Configuration Guide has a ThinkWatch Lite tab**, first among the
AI gateway clients: what Lite does, and a button that opens the new-key
dialog filled in for it (named "ThinkWatch Lite", AI gateway only).
Users without `api_keys:create` are told how to get a key and add it by
hand.
- **`GET /api/gateway/info`** on the console port gives any signed-in user
the gateway's public base URL, `{"base_url": "https://gateway.example.com"}`,
from `general.public_protocol`, `general.public_host` and
`general.public_port`. What is not set comes from the address the console
was opened at: the scheme from `X-Forwarded-Proto` (else `http`), the
host from `X-Forwarded-Host` or `Host`, the port from the gateway's
listening port. A default port is left out.

### Fixed

- **The Configuration Guide shows the configured gateway address to every
user.** It read the address from `GET /api/admin/settings/system`, which
needs `settings:read`: everyone else got 403 and an address built from
the browser's host and port 3000, whatever `general.public_*` said. It
now reads `GET /api/gateway/info`.
- **`GET /v1/models` and `GET /v1beta/models` list only the models the
calling key may use**: the key's `allowed_models` intersected with what
its owner's roles grant, matched exactly or by prefix, the check a
request for the model meets. They listed every routed model, and a
request for one outside that scope was then refused.

## [3.5.0] — 2026-10-11

Limits hold where they used to leak: route caps are enforced, a limits
Expand Down
12 changes: 3 additions & 9 deletions crates/gateway/src/lifecycle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -847,16 +847,10 @@ impl Surface for ChatCompletionSurface {
))
}

/// `None` = unrestricted; otherwise the model must match an entry
/// (exactly or by prefix). An empty list allows nothing — it is what
/// a key narrowed to models its owner's roles do not grant ends up
/// with, and the MCP surface reads `[]` the same way.
/// See [`GatewayRequestIdentity::may_call`]. The MCP surface reads
/// an empty list the same way: nothing is allowed.
fn is_access_allowed(identity: &Self::Identity, candidate: &str) -> bool {
identity.allowed_models.as_ref().is_none_or(|allowed| {
allowed
.iter()
.any(|m| candidate == *m || candidate.starts_with(m))
})
identity.may_call(candidate)
}

fn access_denied_response(candidate: &str) -> Self::Response {
Expand Down
37 changes: 37 additions & 0 deletions crates/gateway/src/proxy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,26 @@ pub struct GatewayRequestIdentity {
pub key_expires_at: Option<chrono::DateTime<chrono::Utc>>,
}

impl GatewayRequestIdentity {
/// Whether this caller may call `model` (the name after aliases).
/// `allowed_models` is the key's list intersected with its owner's
/// role grants: `None` is unrestricted; otherwise the model must
/// match an entry exactly or start with it. An empty list allows
/// nothing — it is what a key narrowed to models its owner's roles
/// do not grant ends up with.
///
/// The one decision behind both the request-time check
/// (`check_access`) and the model listings, so `/v1/models` lists
/// exactly the models a request would be let through for.
pub fn may_call(&self, model: &str) -> bool {
self.allowed_models.as_ref().is_none_or(|allowed| {
allowed
.iter()
.any(|m| model == m || model.starts_with(m.as_str()))
})
}
}

/// Thin wrapper kept for call-site readability; delegates to
/// `GatewayError::status_code` so the error-path `gateway_logs` row,
/// the streaming `StreamOutcome::UpstreamError` log row, and
Expand Down Expand Up @@ -467,4 +487,21 @@ mod helper_tests {
};
assert_eq!(err.retry_after_secs(), Some(3600));
}

#[test]
fn a_model_is_callable_when_an_entry_matches_it_exactly_or_as_a_prefix() {
let with = |allowed: Option<&[&str]>| GatewayRequestIdentity {
allowed_models: allowed.map(|a| a.iter().map(|m| m.to_string()).collect()),
..Default::default()
};
assert!(with(None).may_call("anything"));
let narrowed = with(Some(&["gpt-4o", "claude-"]));
assert!(narrowed.may_call("gpt-4o"));
assert!(narrowed.may_call("gpt-4o-mini"));
assert!(narrowed.may_call("claude-sonnet-4-5"));
assert!(!narrowed.may_call("gpt-4"));
assert!(!narrowed.may_call("gemini-2.5-pro"));
// An empty list allows nothing.
assert!(!with(Some(&[])).may_call("gpt-4o"));
}
}
39 changes: 27 additions & 12 deletions crates/gateway/src/proxy/models.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
//! `GET /v1/models` and Gemini's `GET /v1beta/models` — the models this
//! gateway routes, in the shape each kind of client reads.
//! gateway routes that the calling key may use, in the shape each kind of
//! client reads.

use axum::Json;
use axum::extract::State;
use axum::{Extension, Json};

use super::GatewayState;
use super::{GatewayRequestIdentity, GatewayState};

/// The routed models `identity` may call: each name the router serves,
/// put through the same alias mapping and model check a request for it
/// would meet. A listed model is one a request for it is let through for,
/// and a model the key may not use is not listed.
fn callable_models(state: &GatewayState, identity: &GatewayRequestIdentity) -> Vec<String> {
state
.router
.load()
.list_models()
.into_iter()
.filter(|id| identity.may_call(&state.model_mapper.map(id)))
.collect()
}

/// GET /v1/models
///
/// Returns the list of available models in OpenAI-compatible format.
pub async fn list_models_handler(State(state): State<GatewayState>) -> Json<serde_json::Value> {
let models = state.router.load().list_models();

let model_objects: Vec<serde_json::Value> = models
/// Returns the models the calling key may use, in OpenAI-compatible
/// format.
pub async fn list_models_handler(
State(state): State<GatewayState>,
Extension(identity): Extension<GatewayRequestIdentity>,
) -> Json<serde_json::Value> {
let model_objects: Vec<serde_json::Value> = callable_models(&state, &identity)
.into_iter()
.map(|id| {
serde_json::json!({
Expand All @@ -36,11 +53,9 @@ pub async fn list_models_handler(State(state): State<GatewayState>) -> Json<serd
/// its clients strip.
pub async fn list_gemini_models_handler(
State(state): State<GatewayState>,
Extension(identity): Extension<GatewayRequestIdentity>,
) -> Json<serde_json::Value> {
let models: Vec<serde_json::Value> = state
.router
.load()
.list_models()
let models: Vec<serde_json::Value> = callable_models(&state, &identity)
.into_iter()
.map(|id| {
serde_json::json!({
Expand Down
4 changes: 4 additions & 0 deletions crates/server/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,10 @@ pub fn create_console_app(config: &AppConfig, state: AppState) -> anyhow::Result
get(handlers::mcp_store::get_template),
)
.route("/api/mcp/logs", get(handlers::mcp_logs::list_mcp_logs))
.route(
"/api/gateway/info",
get(handlers::gateway_info::get_gateway_info),
)
.route(
"/api/gateway/logs",
get(handlers::gateway_logs::list_gateway_logs),
Expand Down
Loading
Loading