diff --git a/config/gateway.example.toml b/config/gateway.example.toml index ee5508b..b67e9a3 100644 --- a/config/gateway.example.toml +++ b/config/gateway.example.toml @@ -71,7 +71,7 @@ openai_chat = true openai_completions = true openai_embeddings = true openai_models = true -anthropic_messages = false +anthropic_messages = true mcp = false native_passthrough = true diff --git a/src/admin/mod.rs b/src/admin/mod.rs index bf3ac6f..d89994c 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -160,6 +160,7 @@ pub fn api_routes(admin_token: String) -> Router { "/admin/api/protocols", get(get_protocols).put(put_protocols), ) + .route("/admin/api/harnesses", get(get_harnesses)) .route("/admin/api/settings", get(get_settings).put(put_settings)) .route("/admin/api/keys", get(list_keys).post(create_key)) .route("/admin/api/keys/:key", axum::routing::delete(delete_key)) @@ -989,6 +990,84 @@ async fn put_routing(State(state): State, Json(body): Json Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(std::path::PathBuf::from) +} + +fn find_bin(name: &str) -> bool { + let exts: &[&str] = if cfg!(windows) { + &[".exe", ".cmd", ".bat", ""] + } else { + &[""] + }; + let mut dirs: Vec = std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(); + if let Some(home) = harness_home() { + // GUI-launched processes often miss the shell PATH — add usual suspects + for extra in [ + ".local/bin", + ".cargo/bin", + "bin", + ".bun/bin", + ".npm-global/bin", + "AppData/Roaming/npm", + ] { + dirs.push(home.join(extra)); + } + } + dirs.extend(["/usr/local/bin", "/opt/homebrew/bin"].map(std::path::PathBuf::from)); + dirs.iter() + .any(|d| exts.iter().any(|e| d.join(format!("{name}{e}")).is_file())) +} + +/// Does the harness config mention cortiq (i.e. was it pointed at this gateway)? +fn config_mentions_cortiq(rel: &str) -> bool { + harness_home() + .map(|h| h.join(rel)) + .and_then(|p| std::fs::read_to_string(p).ok()) + .map(|c| c.to_lowercase().contains("cortiq")) + .unwrap_or(false) +} + +async fn get_harnesses() -> ApiResult { + let harnesses = tokio::task::spawn_blocking(|| { + let vscode_ext_glob = |marker: &str| -> bool { + let Some(home) = harness_home() else { + return false; + }; + for extdir in [".vscode/extensions", ".vscode-server/extensions"] { + if let Ok(rd) = std::fs::read_dir(home.join(extdir)) { + for e in rd.flatten() { + if e.file_name().to_string_lossy().starts_with(marker) { + return true; + } + } + } + } + false + }; + json!({ + "Claude Code": { "installed": find_bin("claude"), "configured": serde_json::Value::Null }, + "Codex": { "installed": find_bin("codex"), "configured": config_mentions_cortiq(".codex/config.toml") }, + "OpenCode": { "installed": find_bin("opencode"), "configured": config_mentions_cortiq(".config/opencode/opencode.json") }, + "Continue": { "installed": harness_home().map(|h| h.join(".continue").is_dir()).unwrap_or(false), "configured": config_mentions_cortiq(".continue/config.yaml") }, + "Cline": { "installed": vscode_ext_glob("saoudrizwan.claude-dev"), "configured": serde_json::Value::Null }, + }) + }) + .await + .unwrap_or_else(|_| json!({})); + ok(harnesses) +} + // ───────────────────────────── protocols ─────────────────────────────────── async fn get_protocols(State(state): State) -> ApiResult { diff --git a/src/config.rs b/src/config.rs index 87a9b6f..4c2ed74 100644 --- a/src/config.rs +++ b/src/config.rs @@ -757,7 +757,9 @@ impl Default for Config { openai_completions: true, openai_embeddings: true, openai_models: true, - anthropic_messages: false, + // on by default: the API-keys view ships a Claude Code + // auto-setup snippet that targets /v1/messages + anthropic_messages: true, mcp: false, native_passthrough: true, }, diff --git a/web/css/styles.css b/web/css/styles.css index 6bcacfe..3eeed80 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -588,6 +588,10 @@ body.modal-open { overflow: hidden; } /* Money: 4 decimals, last two de-emphasized */ .money-tail { font-size: .72em; opacity: .75; margin-left: 1px; } +.seg { flex-wrap: wrap; } + +.ok { color: var(--good); } + /* Benchmark view */ @keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } } tbody tr:last-child { animation: bm-in .25s ease; } diff --git a/web/js/api.js b/web/js/api.js index ed46b36..276bfd2 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -73,6 +73,7 @@ export const api = { // protocols getProtocols: () => j("GET", "/protocols"), + harnesses: () => j("GET", "/harnesses"), putProtocols: (b) => j("PUT", "/protocols", b), // settings diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js index cf0b27f..3bec4b8 100644 --- a/web/js/i18n/de.js +++ b/web/js/i18n/de.js @@ -602,4 +602,18 @@ export default { "bm.spent": "ausgegeben", "bm.verdict": "🏁 Fertig: Antworten {p}, ausgegeben {s}", "bm.clearHistory": "Alle löschen", + + // harness auto-setup (API keys view) + "keys.h.claude": "Claude Code — ins Terminal einfügen (das Gateway spricht das Anthropic-Protokoll nativ):", + "keys.h.codex": "Codex — hängt den Provider an ~/.codex/config.toml an und startet:", + "keys.h.opencode": "OpenCode — schreibt ~/.config/opencode/opencode.json (überschreibt!) und startet:", + "keys.h.continue": "Continue (VS Code / JetBrains) — hängt das Modell an ~/.continue/config.yaml an:", + "keys.h.cline1": "Cline (VS Code) — in den Erweiterungseinstellungen wählen:", + "keys.h.cline2": "Einstellungen → API Provider → OpenAI Compatible, dann die Werte oben einfügen.", + "keys.h.note": "cortiq-auto = smartes Routing: der Router wählt das Modell pro Anfrage. Für ein festes Modell den exakten Namen angeben. Unter Windows die Skripte in Git Bash / WSL ausführen oder dieselben Variablen über die Systemumgebung setzen.", + "keys.h.protoOff": "Das Anthropic-Protokoll (/v1/messages) ist deaktiviert — Claude Code kann sich nicht verbinden.", + "keys.h.enableProto": "Aktivieren", + "keys.h.st.installed": "Auf der Gateway-Maschine installiert", + "keys.h.st.configured": "Bereits für dieses Gateway konfiguriert", + "keys.h.st.notFound": "Auf der Gateway-Maschine nicht gefunden", }; diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 992ceb1..4883f18 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -595,4 +595,18 @@ export default { "bm.spent": "spent", "bm.verdict": "🏁 Done: answers {p}, spent {s}", "bm.clearHistory": "Clear all", + + // harness auto-setup (API keys view) + "keys.h.claude": "Claude Code — paste into the terminal (the gateway speaks the Anthropic protocol natively):", + "keys.h.codex": "Codex — appends the provider to ~/.codex/config.toml and starts:", + "keys.h.opencode": "OpenCode — writes ~/.config/opencode/opencode.json (overwrites!) and starts:", + "keys.h.continue": "Continue (VS Code / JetBrains) — appends the model to ~/.continue/config.yaml:", + "keys.h.cline1": "Cline (VS Code) — in extension settings pick:", + "keys.h.cline2": "Settings → API Provider → OpenAI Compatible, then paste the values above.", + "keys.h.note": "cortiq-auto = smart routing: the router picks the model per request. Use an exact model name to pin one. On Windows run the scripts in Git Bash / WSL, or set the same variables via the system environment settings.", + "keys.h.protoOff": "The Anthropic protocol (/v1/messages) is disabled — Claude Code will not connect.", + "keys.h.enableProto": "Enable", + "keys.h.st.installed": "Installed on the gateway machine", + "keys.h.st.configured": "Already configured for this gateway", + "keys.h.st.notFound": "Not found on the gateway machine", }; diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js index a5ba6a6..d9e5395 100644 --- a/web/js/i18n/es.js +++ b/web/js/i18n/es.js @@ -602,4 +602,18 @@ export default { "bm.spent": "gastado", "bm.verdict": "🏁 Listo: respuestas {p}, gastado {s}", "bm.clearHistory": "Borrar todo", + + // harness auto-setup (API keys view) + "keys.h.claude": "Claude Code — pega en la terminal (la pasarela habla el protocolo Anthropic de forma nativa):", + "keys.h.codex": "Codex — añade el proveedor a ~/.codex/config.toml y arranca:", + "keys.h.opencode": "OpenCode — escribe ~/.config/opencode/opencode.json (¡sobrescribe!) y arranca:", + "keys.h.continue": "Continue (VS Code / JetBrains) — añade el modelo a ~/.continue/config.yaml:", + "keys.h.cline1": "Cline (VS Code) — en los ajustes de la extensión elige:", + "keys.h.cline2": "Ajustes → API Provider → OpenAI Compatible, luego pega los valores de arriba.", + "keys.h.note": "cortiq-auto = enrutado inteligente: el router elige el modelo en cada petición. Para fijar uno, indica su nombre exacto. En Windows ejecuta los scripts en Git Bash / WSL o define las mismas variables en el entorno del sistema.", + "keys.h.protoOff": "El protocolo Anthropic (/v1/messages) está desactivado — Claude Code no podrá conectarse.", + "keys.h.enableProto": "Activar", + "keys.h.st.installed": "Instalado en la máquina de la pasarela", + "keys.h.st.configured": "Ya configurado para esta pasarela", + "keys.h.st.notFound": "No encontrado en la máquina de la pasarela", }; diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js index 6a32c08..f15444f 100644 --- a/web/js/i18n/fr.js +++ b/web/js/i18n/fr.js @@ -602,4 +602,18 @@ export default { "bm.spent": "dépensé", "bm.verdict": "🏁 Terminé : réponses {p}, dépensé {s}", "bm.clearHistory": "Tout effacer", + + // harness auto-setup (API keys view) + "keys.h.claude": "Claude Code — collez dans le terminal (la passerelle parle nativement le protocole Anthropic) :", + "keys.h.codex": "Codex — ajoute le fournisseur à ~/.codex/config.toml et démarre :", + "keys.h.opencode": "OpenCode — écrit ~/.config/opencode/opencode.json (écrase !) et démarre :", + "keys.h.continue": "Continue (VS Code / JetBrains) — ajoute le modèle à ~/.continue/config.yaml :", + "keys.h.cline1": "Cline (VS Code) — dans les réglages de l'extension, choisissez :", + "keys.h.cline2": "Réglages → API Provider → OpenAI Compatible, puis collez les valeurs ci-dessus.", + "keys.h.note": "cortiq-auto = routage intelligent : le routeur choisit le modèle à chaque requête. Pour en fixer un, indiquez son nom exact. Sous Windows, lancez les scripts dans Git Bash / WSL ou définissez les mêmes variables via l'environnement système.", + "keys.h.protoOff": "Le protocole Anthropic (/v1/messages) est désactivé — Claude Code ne pourra pas se connecter.", + "keys.h.enableProto": "Activer", + "keys.h.st.installed": "Installé sur la machine de la passerelle", + "keys.h.st.configured": "Déjà configuré pour cette passerelle", + "keys.h.st.notFound": "Introuvable sur la machine de la passerelle", }; diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index cf41947..e9326f9 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -594,4 +594,18 @@ export default { "bm.spent": "потрачено", "bm.verdict": "🏁 Готово: ответы {p}, потрачено {s}", "bm.clearHistory": "Очистить всё", + + // harness auto-setup (API keys view) + "keys.h.claude": "Claude Code — вставьте в терминал (шлюз говорит на протоколе Anthropic нативно):", + "keys.h.codex": "Codex — добавит провайдера в ~/.codex/config.toml и запустит:", + "keys.h.opencode": "OpenCode — запишет ~/.config/opencode/opencode.json (перезапишет!) и запустит:", + "keys.h.continue": "Continue (VS Code / JetBrains) — добавит модель в ~/.continue/config.yaml:", + "keys.h.cline1": "Cline (VS Code) — в настройках расширения укажите:", + "keys.h.cline2": "Настройки → API Provider → OpenAI Compatible, затем вставьте значения выше.", + "keys.h.note": "cortiq-auto — умный роутинг: роутер сам выбирает модель под каждый запрос. Чтобы закрепить конкретную, укажите её точное имя. На Windows запускайте скрипты в Git Bash / WSL или задайте те же переменные через настройки окружения системы.", + "keys.h.protoOff": "Протокол Anthropic (/v1/messages) выключен — Claude Code не подключится.", + "keys.h.enableProto": "Включить", + "keys.h.st.installed": "Установлен на машине шлюза", + "keys.h.st.configured": "Уже настроен на этот шлюз", + "keys.h.st.notFound": "Не найден на машине шлюза", }; diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js index 1e35cdd..146f161 100644 --- a/web/js/i18n/tr.js +++ b/web/js/i18n/tr.js @@ -602,4 +602,18 @@ export default { "bm.spent": "harcanan", "bm.verdict": "🏁 Bitti: yanıtlar {p}, harcanan {s}", "bm.clearHistory": "Tümünü temizle", + + // harness auto-setup (API keys view) + "keys.h.claude": "Claude Code — terminale yapıştırın (ağ geçidi Anthropic protokolünü doğal olarak konuşur):", + "keys.h.codex": "Codex — sağlayıcıyı ~/.codex/config.toml dosyasına ekler ve başlatır:", + "keys.h.opencode": "OpenCode — ~/.config/opencode/opencode.json dosyasını yazar (üzerine yazar!) ve başlatır:", + "keys.h.continue": "Continue (VS Code / JetBrains) — modeli ~/.continue/config.yaml dosyasına ekler:", + "keys.h.cline1": "Cline (VS Code) — eklenti ayarlarında şunları seçin:", + "keys.h.cline2": "Ayarlar → API Provider → OpenAI Compatible, ardından yukarıdaki değerleri yapıştırın.", + "keys.h.note": "cortiq-auto = akıllı yönlendirme: yönlendirici her istek için modeli kendisi seçer. Belirli bir model için tam adını yazın. Windows'ta betikleri Git Bash / WSL içinde çalıştırın veya aynı değişkenleri sistem ortamından ayarlayın.", + "keys.h.protoOff": "Anthropic protokolü (/v1/messages) kapalı — Claude Code bağlanamaz.", + "keys.h.enableProto": "Etkinleştir", + "keys.h.st.installed": "Ağ geçidi makinesinde kurulu", + "keys.h.st.configured": "Bu ağ geçidi için zaten yapılandırılmış", + "keys.h.st.notFound": "Ağ geçidi makinesinde bulunamadı", }; diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js index abe55e0..dbbd70c 100644 --- a/web/js/i18n/zh.js +++ b/web/js/i18n/zh.js @@ -602,4 +602,18 @@ export default { "bm.spent": "花费", "bm.verdict": "🏁 完成:回答 {p},花费 {s}", "bm.clearHistory": "全部清除", + + // harness auto-setup (API keys view) + "keys.h.claude": "Claude Code — 粘贴到终端(网关原生支持 Anthropic 协议):", + "keys.h.codex": "Codex — 将提供商追加到 ~/.codex/config.toml 并启动:", + "keys.h.opencode": "OpenCode — 写入 ~/.config/opencode/opencode.json(会覆盖!)并启动:", + "keys.h.continue": "Continue(VS Code / JetBrains)— 将模型追加到 ~/.continue/config.yaml:", + "keys.h.cline1": "Cline(VS Code)— 在扩展设置中选择:", + "keys.h.cline2": "设置 → API Provider → OpenAI Compatible,然后粘贴上面的值。", + "keys.h.note": "cortiq-auto = 智能路由:路由器为每个请求选择模型。要固定某个模型,请填写其精确名称。Windows 上请在 Git Bash / WSL 中运行脚本,或通过系统环境变量设置相同的值。", + "keys.h.protoOff": "Anthropic 协议(/v1/messages)已关闭 — Claude Code 无法连接。", + "keys.h.enableProto": "启用", + "keys.h.st.installed": "已安装在网关所在机器上", + "keys.h.st.configured": "已配置为使用此网关", + "keys.h.st.notFound": "网关所在机器上未找到", }; diff --git a/web/js/views/keys.js b/web/js/views/keys.js index 7325598..8d7d98b 100644 --- a/web/js/views/keys.js +++ b/web/js/views/keys.js @@ -58,9 +58,64 @@ export async function renderKeys() { // Keys are stored hashed-masked in the list, so the key itself is a // placeholder unless one was just created on this page. function snippetsCard(openMode) { - const base = location.origin + "/v1"; + const origin = location.origin; + const base = origin + "/v1"; const key = lastCreatedKey || (openMode ? "sk-anything" : "<" + t("keys.snip.keyPh") + ">"); + // Harness auto-setup scripts first (the gateway speaks OpenAI at /v1/* and + // Anthropic at /v1/messages, so Claude Code connects natively), SDKs after. const snips = { + "Claude Code": `# ${t("keys.h.claude")} +export ANTHROPIC_BASE_URL="${origin}" +export ANTHROPIC_AUTH_TOKEN="${key}" +export ANTHROPIC_MODEL="cortiq-auto" + +claude`, + Codex: `# ${t("keys.h.codex")} +mkdir -p ~/.codex && cat >> ~/.codex/config.toml <<'EOF' +model = "cortiq-auto" +model_provider = "cortiq" + +[model_providers.cortiq] +name = "Cortiq Gateway" +base_url = "${base}" +env_key = "CORTIQ_API_KEY" +wire_api = "chat" +EOF +export CORTIQ_API_KEY="${key}" + +codex`, + OpenCode: `# ${t("keys.h.opencode")} +mkdir -p ~/.config/opencode && cat > ~/.config/opencode/opencode.json <<'EOF' +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "cortiq": { + "npm": "@ai-sdk/openai-compatible", + "name": "Cortiq Gateway", + "options": { "baseURL": "${base}", "apiKey": "${key}" }, + "models": { "cortiq-auto": { "name": "Cortiq Auto (smart routing)" } } + } + } +} +EOF + +opencode`, + Continue: `# ${t("keys.h.continue")} +mkdir -p ~/.continue && cat >> ~/.continue/config.yaml <<'EOF' +models: + - name: Cortiq Auto + provider: openai + model: cortiq-auto + apiBase: ${base} + apiKey: ${key} +EOF`, + Cline: `${t("keys.h.cline1")} + API Provider: OpenAI Compatible + Base URL: ${base} + API Key: ${key} + Model ID: cortiq-auto + +${t("keys.h.cline2")}`, curl: `curl ${base}/chat/completions \\ -H "Authorization: Bearer ${key}" \\ -H "Content-Type: application/json" \\ @@ -82,20 +137,65 @@ const resp = await client.chat.completions.create({ }); console.log(resp.choices[0].message.content);`, }; - let cur = "curl"; + let cur = "Claude Code"; const pre = h("pre", { class: "conv-log snippet" }, snips[cur]); const seg = h( "div", { class: "seg" }, ...Object.keys(snips).map((k, i) => - h("button", { class: i === 0 ? "active" : "", onClick: (e) => { + h("button", { class: (i === 0 ? "active" : "") + (["curl", "python", "javascript"].includes(k) ? " sdk" : ""), "data-snip": k, onClick: (e) => { cur = k; pre.textContent = snips[k]; + protoWarn.style.display = k === "Claude Code" && protoWarn.childNodes.length ? "" : "none"; + paintStatus(); seg.querySelectorAll("button").forEach((b) => b.classList.remove("active")); - e.target.classList.add("active"); + e.currentTarget.classList.add("active"); } }, k) ) ); + // Claude Code needs the anthropic_messages protocol — offer a one-click + // enable when it is off, right where the user will hit the problem. + const protoWarn = h("div", { style: "display:none; margin-top:8px" }); + api.getProtocols().then((prot) => { + if (prot.anthropic_messages) return; + const fixBtn = h("button", { class: "btn sm primary" }, t("keys.h.enableProto")); + fixBtn.addEventListener("click", async () => { + try { + await api.putProtocols({ ...prot, anthropic_messages: true }); + protoWarn.style.display = "none"; + toast(t("common.saved"), "ok"); + } catch (e) { + toast(String(e.message || e), "err"); + } + }); + mount(protoWarn, h("div", { class: "callout warn" }, h("span", {}, "⚠ " + t("keys.h.protoOff")), " ", fixBtn)); + if (cur === "Claude Code") protoWarn.style.display = ""; + }).catch(() => {}); + + // Detected harnesses: ✓ on the tab when installed on the gateway machine, + // status line under the snippet ("installed" / "already configured"). + let detected = {}; + const statusHost = h("div", { class: "small", style: "margin-top:6px" }); + function paintStatus() { + const d = detected[cur]; + if (!d) { + statusHost.textContent = ""; + return; + } + statusHost.innerHTML = ""; + if (d.configured) mount(statusHost, h("span", { class: "ok" }, "✓ " + t("keys.h.st.configured"))); + else if (d.installed) mount(statusHost, h("span", { class: "ok" }, "✓ " + t("keys.h.st.installed"))); + else mount(statusHost, h("span", { class: "muted" }, "○ " + t("keys.h.st.notFound"))); + } + api.harnesses().then((d) => { + detected = d || {}; + seg.querySelectorAll("button").forEach((b) => { + const st = detected[b.dataset.snip]; + if (st && st.installed) b.append(" ✓"); + }); + paintStatus(); + }).catch(() => {}); + const copy = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy")); copy.addEventListener("click", async () => { await navigator.clipboard.writeText(pre.textContent); @@ -107,8 +207,11 @@ console.log(resp.choices[0].message.content);`, h("div", { class: "card-title" }, "🔌 " + t("keys.snip.title")), h("div", { class: "muted small", style: "margin-bottom:8px" }, lastCreatedKey ? t("keys.snip.withKey") : openMode ? t("keys.snip.openMode") : t("keys.snip.hint")), - h("div", { class: "flex", style: "gap:8px; margin-bottom:8px" }, seg, h("div", { class: "grow" }), copy), - pre + h("div", { class: "flex", style: "gap:8px; margin-bottom:8px; flex-wrap:wrap" }, seg, h("div", { class: "grow" }), copy), + pre, + statusHost, + protoWarn, + h("div", { class: "muted small", style: "margin-top:6px" }, t("keys.h.note")) ); }