Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
3355920
feat(installer): detect Termux and skip incompatible setup steps (#1764)
diegosouzapw May 19, 2026
6756006
feat(errors): expose sanitized upstream error details in client respo…
diegosouzapw May 19, 2026
e57cf43
feat(kiro): isolate OAuth sessions per connection for multi-account s…
diegosouzapw May 19, 2026
8b8bb3d
feat(cli): add providers rotate command for upstream key rotation (#1…
diegosouzapw May 19, 2026
e7eb777
feat(providers): add t3.chat web provider skeleton (#1909)
diegosouzapw May 19, 2026
3b9cb7d
feat(zed): complete Docker integration with manual import endpoint + …
diegosouzapw May 19, 2026
4bc6b33
fix(combo): blend output token cost into auto-combo scoring (#1812)
diegosouzapw May 19, 2026
bfe90c0
feat(combo): filter auto-combo candidates by context window (#1808)
diegosouzapw May 19, 2026
5040c8b
feat(combo): track provider-level exhaustion across combo targets (#1…
diegosouzapw May 19, 2026
db674c8
chore(tests): move #1731 integration test to tests/integration/
diegosouzapw May 19, 2026
61683e0
chore(privacy): untrack implement-features workflow/skill/command (ke…
diegosouzapw May 19, 2026
b9af455
docs: fix audit gaps — CHANGELOG entry for #2306 + AUTO-COMBO blended…
diegosouzapw May 19, 2026
a9cef6d
chore(merge): resolve combo.ts conflict — preserve exhaustedProviders…
diegosouzapw May 20, 2026
b50dfb9
fix: restore v3.8.0 state for regressive files — prevent auth/gamific…
diegosouzapw May 20, 2026
460e4e7
chore: merge release/v3.8.0 into feat/features-batch — resolve all co…
diegosouzapw May 20, 2026
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
888 changes: 0 additions & 888 deletions .agents/skills/implement-features/SKILL.md

This file was deleted.

881 changes: 0 additions & 881 deletions .agents/workflows/implement-features-ag.md

This file was deleted.

881 changes: 0 additions & 881 deletions .claude/commands/implement-features-cc.md

This file was deleted.

6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,9 @@ http-client.private.env.json

# Feature-triage ephemeral artifact (regenerated each run)
_ideia/_triage.json

# Private workflow / skill / command implementations
# These contain proprietary multi-phase logic and should not be committed
.agents/workflows/implement-features-ag.md
.agents/skills/implement-features/
.claude/commands/implement-features-cc.md
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- **feat(zed):** Zed IDE Docker support — when OmniRoute runs in Docker and Zed is on the host, the Import flow now returns a 422 with `zedDockerEnvironment: true` and the dashboard auto-expands a Manual Token Import panel (new `POST /api/providers/zed/manual-import` endpoint with Zod validation). Includes Docker detection utility (`/.dockerenv` + cgroup heuristics) and a setup guide at [`docs/providers/ZED-DOCKER.md`](docs/providers/ZED-DOCKER.md). ([#2306])
- **feat(workflow):** `/implement-features` gains pre-flight triage script (`scripts/features/feature-triage.mjs`) classifying open feature requests into 8 buckets — fresh issues (<14d) stay dormant to give the community time to react, engagement override (≥5 👍 or ≥3 unique non-bot commenters) absorbs early, already-delivered detection via merged PRs + CHANGELOG + git log closes issues with version + PR reference, stale `need_details/` (>30d) is closed politely, aged `defer/` (>90d) is re-evaluated, and externally-closed issues clean up `_ideia/` automatically. Idea files now carry a YAML frontmatter snapshot enabling incremental comment re-sync. 53 unit tests cover the new logic.
- **feat(providers):** add GitHub Models as a free provider — GPT-5, o-series, DeepSeek-R1, Llama 4, Grok 3 with GitHub PAT auth and dynamic model fetch from `api.github.com`. ([#2344](https://github.com/diegosouzapw/OmniRoute/pull/2344) — thanks @oyi77)
- **feat(providers):** add Hackclub AI as a free provider — 30+ models, no credit card required, optional API key auth with passthrough model support. ([#2339](https://github.com/diegosouzapw/OmniRoute/pull/2339) — thanks @oyi77)
Expand All @@ -20,6 +21,7 @@
- **feat(providers):** improve Cohere provider support, expanding models and accurately updating OpenAI context limits. ([#2313](https://github.com/diegosouzapw/OmniRoute/pull/2313) — thanks @backryun)
- **feat(claude-web):** implement session-based Claude Web executor with auto-refresh authentication — enables direct Claude Web API access without an API key. ([#2283](https://github.com/diegosouzapw/OmniRoute/pull/2283) — thanks @oyi77)
- **feat(skills):** add 5 CLI skill manifests + AgentSkills / OmniSkills dashboard pages — enables external AI agents to discover and invoke OmniRoute capabilities. ([#2284](https://github.com/diegosouzapw/OmniRoute/pull/2284))
- **feat(providers):** add llama.cpp as local provider — `llama-cpp` (alias `llamacpp`) added to `LOCAL_PROVIDERS` and `SELF_HOSTED_CHAT_PROVIDER_IDS`; default base URL `http://127.0.0.1:8080/v1`; no API key required; uses the default OpenAI-compatible executor ([#1980](https://github.com/diegosouzapw/OmniRoute/issues/1980))
- **feat(providers):** bulk add API keys with Single/Bulk tabs.
- **feat(provider):** add Gitlawb Opengateway provider (xiaomi-mimo + gmi-cloud) with hasFree flag support. ([#2314](https://github.com/diegosouzapw/OmniRoute/pull/2314) — thanks @oyi77)
- **feat(ui):** comprehensive dashboard UX rework including simple/advanced modes for RTK/Caveman, human-readable error badges, InfoTooltip/PresetSlider shared components, sidebar subtitles, and provider category filters. ([#2315](https://github.com/diegosouzapw/OmniRoute/pull/2315), [#2316](https://github.com/diegosouzapw/OmniRoute/pull/2316) — thanks @dhaern, @oyi77)
Expand Down
203 changes: 202 additions & 1 deletion bin/cli/commands/providers.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { apiFetch } from "../api.mjs";
import { apiFetch, isServerUp } from "../api.mjs";
import { emit } from "../output.mjs";
import { printHeading } from "../io.mjs";
import { getAvailableProviderCategories, loadAvailableProviders } from "../provider-catalog.mjs";
Expand All @@ -7,8 +7,10 @@ import {
findProviderConnection,
getProviderApiKey,
listProviderConnections,
updateProviderApiKey,
updateProviderTestResult,
} from "../provider-store.mjs";
import { encryptCredential } from "../encryption.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { t } from "../i18n.mjs";

Expand Down Expand Up @@ -301,6 +303,179 @@ export async function runValidateCommand(opts = {}) {
}
}

export async function runProvidersRotateCommand(selector, opts = {}) {
if (!selector) {
console.error("Provider connection id or name is required.");
return 2;
}

// --- Resolve connection ---
const { db } = await openOmniRouteDb();
let connection;
try {
connection = findProviderConnection(db, selector);
} finally {
db.close();
}

if (!connection) {
console.error(`Provider connection not found: ${selector}`);
return 2;
}

// --- OAuth short-circuit ---
if (opts.oauth || connection.authType !== "apikey") {
console.log(t("providers.rotate.oauthHint", { provider: connection.provider }));
return 0;
}

// --- Source new key ---
let newKey;
if (opts.fromEnv) {
newKey = process.env[opts.fromEnv];
if (!newKey) {
console.error(t("providers.rotate.envVarEmpty", { var: opts.fromEnv }));
return 2;
}
} else if (opts.newKey) {
newKey = opts.newKey;
} else {
// Interactive prompt (echo-off not strictly needed for a key value, but best practice)
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
newKey = await new Promise((resolve) =>
rl.question(`New API key for ${connection.name}: `, (a) => { rl.close(); resolve(a.trim()); })
);
if (!newKey) {
console.error("No key provided.");
return 2;
}
}

// --- Dry-run ---
if (opts.dryRun) {
console.log(t("providers.rotate.dryRunResult", { name: connection.name, id: connection.id.slice(0, 8) }));
return 0;
}

// --- Confirm ---
if (!opts.yes) {
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) =>
rl.question(t("providers.rotate.confirmPrompt", { name: connection.name, id: connection.id.slice(0, 8) }), resolve)
);
rl.close();
if (!/^y(es|s)?$/i.test(answer)) {
console.log(t("common.cancelled"));
return 0;
}
}

// --- Write ---
const serverUp = await isServerUp();
if (serverUp) {
try {
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
method: "PATCH",
body: { apiKey: newKey, testStatus: "unknown", lastError: null, rateLimitedUntil: null, backoffLevel: 0 },
retry: false,
acceptNotOk: true,
});
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}
} catch {
// Fall through to direct DB write
const { db: db2 } = await openOmniRouteDb();
try {
updateProviderApiKey(db2, connection.id, encryptCredential(newKey));
} finally {
db2.close();
}
}
} else {
const { db: db2 } = await openOmniRouteDb();
try {
updateProviderApiKey(db2, connection.id, encryptCredential(newKey));
} finally {
db2.close();
}
}
Comment on lines +387 to +422

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for updating the provider API key in the database is duplicated across the try-catch and else blocks. Additionally, the database is opened and closed multiple times (openOmniRouteDb). Consider refactoring the database write into a single block and keeping the connection open if multiple operations are needed to improve efficiency.


console.log(t("providers.rotate.success", { name: connection.name, id: connection.id.slice(0, 8) }));

// --- Post-rotation test ---
if (!opts.skipTest) {
const { db: db3 } = await openOmniRouteDb();
try {
const fresh = findProviderConnection(db3, connection.id);
if (fresh) {
const result = await runProviderTest(db3, fresh);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The function runProviderTest is used here but it is not imported or defined within this module. This will cause a runtime error when the command attempts to perform a post-rotation connectivity test.

if (result.valid) {
console.log(t("providers.rotate.testPassed"));
} else {
console.error(t("providers.rotate.testFailed", { error: result.error }));
}
}
} finally {
db3.close();
}
}

return 0;
}

export async function runProvidersStatusCommand(opts = {}) {
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("providers.status.requiresServer"));
return 3;
}

const res = await apiFetch("/api/providers/expiration", { acceptNotOk: true, retry: false });
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}

const data = await res.json();
const list = data.list || [];

// Optional provider filter
const filter = opts.provider ? String(opts.provider).toLowerCase() : null;
const rows = filter ? list.filter((item) => item.provider?.toLowerCase().includes(filter)) : list;

if (opts.json || opts.output === "json") {
console.log(JSON.stringify({ count: rows.length, connections: rows }, null, 2));
return 0;
}

if (rows.length === 0) {
console.log(t("providers.status.noData"));
return 0;
}

console.log(t("providers.status.header"));
for (const item of rows) {
const shortId = (item.connectionId || item.id || "").slice(0, 8);
const expiry = item.expiresAt ? new Date(item.expiresAt).toLocaleDateString() : "-";
const expiryStatus = item.status || "unknown";
const testStatus = item.testStatus || "unknown";
const cooldown = item.rateLimitedUntil ? new Date(item.rateLimitedUntil).toLocaleString() : "-";
const expiryColor = statusColor(expiryStatus);
const testColor = statusColor(testStatus);
Comment on lines +486 to +487

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The function statusColor is used but not defined or imported in this file. This will cause the providers status command to crash when attempting to format the output table.

console.log(
`${shortId.padEnd(10)} ${String(item.provider || "").padEnd(14)} ${String(item.name || "").padEnd(24)} ` +
`${expiry.padEnd(12)} ${expiryColor}${expiryStatus.padEnd(8)}\x1b[0m ` +
`${testColor}${testStatus.padEnd(12)}\x1b[0m ${cooldown}`
);
}

return 0;
}

export function registerProviders(program) {
const providers = program.command("providers").description(t("providers.title"));

Expand Down Expand Up @@ -357,6 +532,32 @@ export function registerProviders(program) {
if (exitCode !== 0) process.exit(exitCode);
});

providers
.command("rotate <idOrName>")
.description(t("providers.rotate.description"))
.option("--new-key <key>", t("providers.rotate.newKeyOpt"))
.option("--from-env <VAR>", t("providers.rotate.fromEnvOpt"))
.option("--oauth", t("providers.rotate.oauthOpt"))
.option("--yes", t("common.yesOpt"))
.option("--skip-test", t("providers.rotate.skipTestOpt"))
.option("--dry-run", t("providers.rotate.dryRunOpt"))
.action(async (idOrName, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runProvidersRotateCommand(idOrName, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});

providers
.command("status")
.description(t("providers.status.description"))
.option("--provider <name>", t("providers.status.providerOpt"))
.option("--json", "Print machine-readable JSON")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runProvidersStatusCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});

extendProvidersMetrics(providers);
}

Expand Down
3 changes: 2 additions & 1 deletion bin/cli/commands/serve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { platform } from "node:os";
import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..", "..");
Expand Down Expand Up @@ -330,7 +331,7 @@ async function onReady(dashboardPort, apiPort, noOpen) {
\x1b[2m Press Ctrl+C to stop\x1b[0m
`);

if (!noOpen) {
if (!noOpen && !isTermux()) {
try {
const open = await import("open");
await open.default(dashboardUrl);
Expand Down
22 changes: 22 additions & 0 deletions bin/cli/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@
},
"metric_single": {
"description": "Get a single metric value for a specific connection"
},
"rotate": {
"description": "Rotate the upstream API key for a provider connection",
"newKeyOpt": "New API key value (avoid: prefer --from-env)",
"fromEnvOpt": "Read new key from environment variable VAR",
"oauthOpt": "Trigger OAuth re-authentication flow instead",
"skipTestOpt": "Skip post-rotation connectivity test",
"dryRunOpt": "Print what would change without writing",
"confirmPrompt": "Replace API key for connection \"{name}\" ({id})? [y/N] ",
"dryRunResult": "[dry-run] Would rotate key for \"{name}\" ({id}). No changes made.",
"oauthHint": "OAuth connection — run: omniroute oauth {provider}",
"envVarEmpty": "Environment variable {var} is not set or is empty.",
"success": "Key rotated for \"{name}\". Run `providers test {id}` to verify.",
"testPassed": "Post-rotation test passed.",
"testFailed": "Post-rotation test failed: {error}"
},
"status": {
"description": "Show key health for all provider connections (age, expiry, cooldown)",
"providerOpt": "Filter by provider name",
"header": "ID Provider Name Expiry Status Test Status Cooldown Until",
"noData": "No provider connection data available.",
"requiresServer": "providers status requires the OmniRoute server to be running."
}
},
"keys": {
Expand Down
22 changes: 22 additions & 0 deletions bin/cli/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@
},
"metric_single": {
"description": "Obter um único valor de métrica para uma conexão específica"
},
"rotate": {
"description": "Rotacionar a chave de API upstream de uma conexão de provedor",
"newKeyOpt": "Novo valor de chave de API (evite: prefira --from-env)",
"fromEnvOpt": "Ler nova chave da variável de ambiente VAR",
"oauthOpt": "Iniciar fluxo de reautenticação OAuth",
"skipTestOpt": "Pular teste de conectividade pós-rotação",
"dryRunOpt": "Exibir o que seria alterado sem gravar",
"confirmPrompt": "Substituir a chave de API da conexão \"{name}\" ({id})? [s/N] ",
"dryRunResult": "[dry-run] Rotacionaria a chave para \"{name}\" ({id}). Nenhuma alteração feita.",
"oauthHint": "Conexão OAuth — execute: omniroute oauth {provider}",
"envVarEmpty": "A variável de ambiente {var} não está definida ou está vazia.",
"success": "Chave rotacionada para \"{name}\". Execute `providers test {id}` para verificar.",
"testPassed": "Teste pós-rotação aprovado.",
"testFailed": "Teste pós-rotação falhou: {error}"
},
"status": {
"description": "Exibir saúde das chaves de todas as conexões de provedores (idade, validade, cooldown)",
"providerOpt": "Filtrar por nome do provedor",
"header": "ID Provedor Nome Validade Status Status Teste Cooldown Até",
"noData": "Nenhum dado de conexão de provedor disponível.",
"requiresServer": "providers status requer o servidor OmniRoute em execução."
}
},
"keys": {
Expand Down
26 changes: 26 additions & 0 deletions bin/cli/provider-store.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,32 @@ export function removeProviderConnectionByProvider(db, provider) {
return result.changes;
}

/**
* Replace the encrypted API key for a connection and clear any cooldown state.
* `encryptedKey` must already be passed through `encryptCredential()`.
*/
export function updateProviderApiKey(db, connectionId, encryptedKey) {
ensureProviderSchema(db);
const now = new Date().toISOString();
const result = db
.prepare(
`UPDATE provider_connections
SET api_key = @apiKey,
test_status = 'unknown',
last_error = NULL,
last_error_at = NULL,
last_error_type = NULL,
last_error_source = NULL,
error_code = NULL,
rate_limited_until = NULL,
backoff_level = 0,
updated_at = @updatedAt
WHERE id = @id`
)
.run({ id: connectionId, apiKey: encryptedKey, updatedAt: now });
return result.changes;
}

export function updateProviderTestResult(db, connectionId, result) {
ensureProviderSchema(db);
const now = new Date().toISOString();
Expand Down
Loading
Loading