Compare commits

...
8 Commits
Author SHA1 Message Date
infosave2007 ad438a5dcb chore: bump v0.2.36
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-07-25 12:01:52 +03:00
infosave2007 45def5c6b6 fix(docker): model downloads failed in containers (curl exit 23, glibc mismatch)
- pin builder and runtime to the same Debian release (trixie): the floating
  rust:1-slim builder drifted to a newer glibc than the bookworm runtime, so
  freshly built images crashed on start with 'GLIBC_2.39 not found'
- install curl in the runtime image — ready-.cmf imports download with it
- keep all gateway state under /app/data (CORTIQ_GATEWAY_HOME) and run as
  root so bind-mounted host folders on Docker Desktop are writable; pass
  --user 10001 to harden when not bind-mounting
- persist /app/data via a cortiq-data volume in docker-compose
- fail imports immediately with the path and reason when the models dir
  cannot be created, and annotate curl exit codes 23/22 with readable hints
- exclude local models/*.cmf from the docker build context
2026-07-25 12:01:38 +03:00
infosave2007 5d8455b4a0 v0.2.35
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-07-23 21:25:45 +03:00
infosave2007 b255c60b0b chore: update lockfile 2026-07-23 16:41:58 +03:00
infosave2007 a75cacd0ec chore: bump v0.2.34
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-07-23 16:41:08 +03:00
infosave2007 850a54acdf fix: enable_thinking not sent in streaming path
Two bugs:
1. run_stream() never merged per-model/server config (think_budget,
   temperature, etc.) into the request — unlike run_inner(). Now both
   paths apply the same config merge.
2. to_wire() was missing the enable_thinking bool conversion line
   (lost in a prior revert). Restored: think_budget=0 → enable_thinking=false.
2026-07-23 16:34:49 +03:00
infosave2007 22110ac524 style(admin): apply cargo fmt to fix CI 2026-07-22 19:04:12 +03:00
infosave2007 b3b37d27f4 chore: update Cargo.lock 2026-07-22 18:59:46 +03:00
12 changed files with 187 additions and 29 deletions
+2
View File
@@ -1,4 +1,6 @@
target
models
*.cmf
.git
.github
docs
+15 -1
View File
@@ -3,7 +3,21 @@
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [0.2.32] - 2026-07-22
## [0.2.36] - 2026-07-25
### Fixed
- **Docker image failed to start when built fresh (`GLIBC_2.39 not found`)**: the floating `rust:1-slim` builder had drifted to Debian trixie while the runtime stayed on bookworm, so a locally built container crashed on startup. Builder and runtime are now pinned to the same Debian release (trixie).
- **Docker model downloads (curl exit code 23)**: the runtime image was missing `curl`, and the default models dir (`$HOME/.cortiq-gateway/models`) was not writable by the container user, so importing a ready `.cmf` model failed with a write error. The image now bundles `curl`, keeps all gateway state under `/app/data` (`CORTIQ_GATEWAY_HOME`), and runs as root so bind-mounted host folders (Docker Desktop on Windows/macOS) are writable; pass `--user 10001` to harden when not using host bind mounts.
- **Docker Compose persistence**: added a `cortiq-data` volume at `/app/data` so downloaded models and stats survive `docker compose up --force-recreate`.
- **Clear import errors**: a models dir that cannot be created now fails the import immediately with the path and reason instead of a cryptic curl exit code; curl exits 23 (write error) and 22 (HTTP error) are annotated with a human-readable hint in the job log.
## [0.2.35] - 2026-07-23
### Fixed
- **Streaming Pipeline System Prompt Merging**: Fixed system prompt injection in `run_stream` so configured model system prompts and reasoning suppression directives are properly passed into streaming requests.
- **CMF Reasoning Mode Compatibility**: Fully integrated with `cortiq-engine` v0.5.17 for sampler-level logit bias reasoning token suppression and zero-token thinking bypass.
## [0.2.34] - 2026-07-23
### Added
- **Responsive mobile admin console**: Added a hamburger navigation drawer, touch-friendly controls, phone-sized forms and cards, scrollable tables, compact toolbars, safe-area support, and mobile bottom-sheet dialogs across the admin interface.
Generated
+1 -1
View File
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cortiq-gateway"
version = "0.2.32"
version = "0.2.36"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cortiq-gateway"
version = "0.2.33"
version = "0.2.36"
edition = "2021"
rust-version = "1.75"
license = "Apache-2.0"
+13 -5
View File
@@ -1,5 +1,7 @@
# ---- build stage ------------------------------------------------------------
FROM rust:1-slim AS builder
# pin builder and runtime to the same Debian release — a floating `rust:1-slim`
# drifts to a newer glibc than the runtime image and the binary refuses to start
FROM rust:1-slim-trixie AS builder
WORKDIR /app
# the admin SPA (web/) is embedded at compile time via rust-embed, so it must be present
COPY Cargo.toml Cargo.lock ./
@@ -8,18 +10,24 @@ COPY web ./web
RUN cargo build --release
# ---- runtime stage ----------------------------------------------------------
FROM debian:bookworm-slim
FROM debian:trixie-slim
# curl is required at runtime — model imports download ready .cmf files with it
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -r -u 10001 cortiq \
&& mkdir -p /app/config
&& mkdir -p /app/config /app/data/models
COPY --from=builder /app/target/release/cortiq-gateway /usr/local/bin/cortiq-gateway
# ship the example as the default config so `docker run` works out of the box
COPY config/gateway.example.toml /app/config/gateway.toml
RUN chown -R 10001:10001 /app
WORKDIR /app
USER cortiq
# all gateway state (downloaded models, stats, secrets) lives under /app/data —
# mount a volume there to keep it across container recreates
ENV CORTIQ_GATEWAY_HOME=/app/data
# runs as root so writes to bind-mounted host folders (Docker Desktop on
# Windows/macOS) don't fail with permission errors (curl exit code 23);
# add `--user 10001` to docker run to harden when not using host bind mounts
EXPOSE 9000
ENTRYPOINT ["cortiq-gateway"]
CMD ["--config", "/app/config/gateway.toml"]
+6 -2
View File
@@ -54,7 +54,8 @@ One OpenAI-compatible endpoint → automatic model selection from your pool
```bash
# Docker (GitHub Container Registry)
docker run -p 9000:9000 -e CORTIQ_ADMIN_TOKEN=secret \
-v "$PWD/config:/app/config" ghcr.io/infosave2007/cortiq-gateway:latest
-v "$PWD/config:/app/config" -v cortiq-data:/app/data \
ghcr.io/infosave2007/cortiq-gateway:latest
# → admin console at http://localhost:9000/admin
# from crates.io
@@ -67,7 +68,10 @@ cargo build --release # ./target/release/cortiq-gateway
```
The image ships a default config and runs out of the box; mount `/app/config` (with a
`gateway.toml`) to use your own. Tags: `:latest`, `:X.Y.Z`, `:edge` (linux/amd64 + arm64).
`gateway.toml`) to use your own. Downloaded models and stats live in `/app/data` — mount
a volume there so they survive container recreates (a host folder works too, e.g.
`-v "$PWD/models:/app/data/models"`). Tags: `:latest`, `:X.Y.Z`, `:edge`
(linux/amd64 + arm64).
**Docker Compose** — gateway + a local Ollama model in one command:
+5
View File
@@ -17,6 +17,10 @@ services:
CORTIQ_ROUTER_KEY: ${CORTIQ_ROUTER_KEY:-}
volumes:
- ./config/gateway.compose.toml:/app/config/gateway.toml:ro
# downloaded .cmf models + stats persist here across recreates; swap the
# named volume for a host folder (e.g. ./models:/app/data/models) if you
# want the files visible next to the compose file
- cortiq-data:/app/data
depends_on:
- ollama
restart: unless-stopped
@@ -30,4 +34,5 @@ services:
restart: unless-stopped
volumes:
cortiq-data:
ollama:
+56 -15
View File
@@ -476,28 +476,61 @@ async fn provider_models(
let mut obj = json!({ "id": id });
let mut caps = vec![];
if let Some(pricing) = m.get("pricing").filter(|v| !v.is_null()) {
if let Some(prompt) = pricing.get("prompt").and_then(|s| s.as_str()).and_then(|s| s.parse::<f64>().ok()) {
if let Some(prompt) = pricing
.get("prompt")
.and_then(|s| s.as_str())
.and_then(|s| s.parse::<f64>().ok())
{
obj["price_in"] = json!(prompt * 1_000_000.0);
}
if let Some(comp) = pricing.get("completion").and_then(|s| s.as_str()).and_then(|s| s.parse::<f64>().ok()) {
if let Some(comp) = pricing
.get("completion")
.and_then(|s| s.as_str())
.and_then(|s| s.parse::<f64>().ok())
{
obj["price_out"] = json!(comp * 1_000_000.0);
}
if pricing.get("input_cache_read").filter(|v| !v.is_null()).is_some() || pricing.get("input_cache_write").filter(|v| !v.is_null()).is_some() {
if pricing
.get("input_cache_read")
.filter(|v| !v.is_null())
.is_some()
|| pricing
.get("input_cache_write")
.filter(|v| !v.is_null())
.is_some()
{
caps.push("caching");
}
}
if let Some(params) = m.get("supported_parameters").and_then(|p| p.as_array()) {
let has_tools = params.iter().any(|p| p.as_str() == Some("tools") || p.as_str() == Some("tool_choice"));
if has_tools { caps.push("tools"); }
let has_tools = params.iter().any(|p| {
p.as_str() == Some("tools") || p.as_str() == Some("tool_choice")
});
if has_tools {
caps.push("tools");
}
let has_json = params.iter().any(|p| p.as_str() == Some("response_format"));
if has_json { caps.push("json"); }
let has_reasoning = params.iter().any(|p| p.as_str() == Some("reasoning") || p.as_str() == Some("include_reasoning"));
if has_reasoning || m.get("reasoning").filter(|v| !v.is_null()).is_some() { caps.push("reasoning"); }
if has_json {
caps.push("json");
}
let has_reasoning = params.iter().any(|p| {
p.as_str() == Some("reasoning")
|| p.as_str() == Some("include_reasoning")
});
if has_reasoning || m.get("reasoning").filter(|v| !v.is_null()).is_some() {
caps.push("reasoning");
}
}
if let Some(arch) = m.get("architecture").filter(|v| !v.is_null()) {
if let Some(inputs) = arch.get("input_modalities").and_then(|i| i.as_array()) {
if inputs.iter().any(|i| i.as_str() == Some("image")) { caps.push("vision"); }
if inputs.iter().any(|i| i.as_str() == Some("audio")) { caps.push("audio"); }
if let Some(inputs) =
arch.get("input_modalities").and_then(|i| i.as_array())
{
if inputs.iter().any(|i| i.as_str() == Some("image")) {
caps.push("vision");
}
if inputs.iter().any(|i| i.as_str() == Some("audio")) {
caps.push("audio");
}
}
}
if !caps.is_empty() {
@@ -514,15 +547,23 @@ async fn provider_models(
if b.provider == "openrouter" {
if let Some(k) = key {
if let Ok(cred_resp) = client.get("https://openrouter.ai/api/v1/credits")
if let Ok(cred_resp) = client
.get("https://openrouter.ai/api/v1/credits")
.header("authorization", format!("Bearer {k}"))
.send().await
.send()
.await
{
if let Ok(cred_body) = cred_resp.text().await {
let cred_v: Value = serde_json::from_str(&cred_body).unwrap_or(json!({}));
if let Some(data) = cred_v.get("data") {
let total = data.get("total_credits").and_then(|v| v.as_f64()).unwrap_or(0.0);
let usage = data.get("total_usage").and_then(|v| v.as_f64()).unwrap_or(0.0);
let total = data
.get("total_credits")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let usage = data
.get("total_usage")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
resp["balance"] = json!(total - usage);
}
}
+14 -2
View File
@@ -406,7 +406,13 @@ async fn download_cmf_repo(
}
Ok(status) => {
cleanup_output(&output_abs);
store.push_line(&id, format!("✗ download failed: exit code {:?}", status.code()));
// curl 23 = write error: almost always a non-writable models dir
let hint = match status.code() {
Some(23) => " (write error — is the models dir writable?)",
Some(22) => " (HTTP error — repo/file not found or auth required)",
_ => "",
};
store.push_line(&id, format!("✗ download failed: exit code {:?}{hint}", status.code()));
store.finish(&id, false);
}
Err(e) => {
@@ -471,7 +477,13 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
}
}
}
let _ = std::fs::create_dir_all(&cfg.models_dir);
std::fs::create_dir_all(&cfg.models_dir).map_err(|e| {
format!(
"cannot create models dir {}: {e} — check that the gateway has write \
access (in Docker, mount a writable volume at the models path)",
cfg.models_dir
)
})?;
let base = if p.name.trim().is_empty() {
sanitize(p.repo.rsplit('/').next().unwrap_or(&p.repo))
} else {
+7 -1
View File
@@ -99,6 +99,7 @@ async fn main() -> anyhow::Result<()> {
let mut app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/health", get(|| async { "ok" }))
.route("/readyz", get(|| async { "ready" }))
.route("/metrics", get(admin::metrics))
.merge(protocols::build_router());
@@ -109,7 +110,12 @@ async fn main() -> anyhow::Result<()> {
.fallback(admin::assets::fallback);
}
let app = app.with_state(state);
let cors = tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any);
let app = app.with_state(state).layer(cors);
let listener = tokio::net::TcpListener::bind(&listen).await?;
tracing::info!("cortiq-gateway listening on {listen}");
+59 -1
View File
@@ -472,7 +472,65 @@ impl Pipeline {
model_id,
route_source
);
match provider.chat_stream(req.clone()).await {
// Merge per-model/server config (same as run_inner).
let mut req_call = req.clone();
if let Some(m_cfg) = live.cfg.models.iter().find(|m| m.id == model_id) {
if req_call.params.temperature.is_none() {
req_call.params.temperature = m_cfg.temperature;
}
if req_call.params.top_p.is_none() {
req_call.params.top_p = m_cfg.top_p;
}
if req_call.params.max_tokens.is_none() {
req_call.params.max_tokens = m_cfg.max_tokens;
}
if req_call.params.think_budget.is_none() {
req_call.params.think_budget = m_cfg.think_budget;
}
if let Some(sys) = &m_cfg.system_prompt {
if !sys.trim().is_empty()
&& !req_call.messages.iter().any(|m| m.role == "system")
{
req_call.messages.insert(
0,
Message {
role: "system".into(),
content: sys.clone(),
tool_calls: Vec::new(),
},
);
}
}
} else if let Some(s_cfg) = live.cfg.cmf.servers.iter().find(|s| s.id == model_id) {
if req_call.params.temperature.is_none() {
req_call.params.temperature = s_cfg.temperature;
}
if req_call.params.top_p.is_none() {
req_call.params.top_p = s_cfg.top_p;
}
if req_call.params.max_tokens.is_none() {
req_call.params.max_tokens = s_cfg.max_tokens;
}
if req_call.params.think_budget.is_none() {
req_call.params.think_budget = s_cfg.think_budget;
}
if let Some(sys) = &s_cfg.system_prompt {
if !sys.trim().is_empty()
&& !req_call.messages.iter().any(|m| m.role == "system")
{
req_call.messages.insert(
0,
Message {
role: "system".into(),
content: sys.clone(),
tool_calls: Vec::new(),
},
);
}
}
}
match provider.chat_stream(req_call).await {
Ok(stream) => {
let info = RouteInfo {
task_label: decision.task_label.clone(),
+8
View File
@@ -48,9 +48,13 @@ impl OpenAiProvider {
if let Some(m) = req.params.max_tokens {
body["max_tokens"] = m.into();
}
eprintln!("[to_wire] think_budget={:?}", req.params.think_budget);
if let Some(tb) = req.params.think_budget {
body["think_budget"] = tb.into();
body["reasoning_effort"] = tb.into();
// cortiq serve uses enable_thinking (bool): 0 = off, >0 = on
body["enable_thinking"] = (tb > 0).into();
eprintln!("[to_wire] SET enable_thinking={}", tb > 0);
}
if !req.tools.is_empty() {
body["tools"] = serde_json::Value::Array(req.tools.clone());
@@ -59,6 +63,10 @@ impl OpenAiProvider {
for (k, v) in &req.params.passthrough {
body[k] = v.clone();
}
eprintln!(
"[to_wire] enable_thinking={:?}",
body.get("enable_thinking")
);
body
}
}