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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- **Codex**: rewrite replayed assistant history `input_text`/`text` parts to `output_text` (dropping `annotations`/`logprobs`/`obfuscation`) so the Codex/OpenAI backend accepts codex-cli conversation replays; user and function items unchanged (OmniRoute #6932).
- **Codex**: echo the client-requested effort-suffixed model id (e.g. `gpt-5.5-xhigh`) in Responses `response.created`/`response.in_progress`/`response.completed` payloads so the Codex CLI status line shows the active effort, without changing the routed upstream model id (OmniRoute #6820).
- **Models**: route `/v1/models` live model-list discovery through the local-first provider-validation SSRF guard (`getProviderValidationGuard`) so LAN-local OpenAI-compatible providers (e.g. LM Studio on 192.168.x.x) appear under default settings while cloud-metadata endpoints stay blocked before any fetch; discovery fetches force `redirect: "manual"` (OmniRoute #6966).
- **Providers**: adding a second API-key connection for the same provider no longer silently overwrites the first — POST /api/providers now runs create-only and returns 409 `PROVIDER_CONNECTION_ALREADY_EXISTS` on a duplicate (provider, apikey, name), the Add-API-key modal pre-fills a unique provider-scoped default name (`main`, `main-2`, …), and PUT /api/providers/[id] remains the explicit update path (OmniRoute #6499).
- **Gemini**: omit unsupported thinking config for Gemma 4 on OpenAI-to-Gemini requests (OmniRoute #6708).
- **Routing**: clamp reasoning-token headroom to explicit model output caps and isolate fallback attempts (#6714).
- **OAuth**: regression-test Codex OAuth connection dedup — Codex same-email logins remain isolated by account and provider, preventing silent token overwrite (OmniRoute #6706; behavior already enforced by account-id-scoped dedup).
Expand Down
2 changes: 1 addition & 1 deletion docs/ports/omniroute-week-2026-07-07.csv
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ number,title,verdict,evidence
6503,"fix(providers): strip reasoning_effort/reasoning from grok-cli requests (#6288)",SKIP,SKIP:not-selected|files:3
6502,"fix(providers): stop Antigravity false quota-exhausted (#6295)",SKIP,SKIP:not-selected|files:3
6501,"fix(compression): add intra-message dedup to session-dedup engine (#6467)",SKIP,SKIP:not-selected|files:6
6499,"fix(ui): prevent silent overwrite of existing API key connections on …",SKIP,SKIP:not-selected|files:7
6499,"fix(ui): prevent silent overwrite of existing API key connections on …",PORT,ported:POST create-only 409 + provider-scoped default name|files:8
commit:fb24740b73,"fix(ci): add the auto-enqueue pull_request_rule to the Mergify config (queue_conditions alone are eligibility-only) (#7179)",SKIP,release-ci-deps-commit
commit:01ab5d1fd5,"chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (#7168)",SKIP,release-ci-deps-commit
commit:7ee5bbc64d,"fix(build): v3.8.48 hotfix — npm tarball head-response-guard (#7065); electron win spawn; Sonar gate zeroed (#7055)",SKIP,release-ci-deps-commit
Expand Down
1 change: 1 addition & 0 deletions docs/providers/omniroute-open-provider-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This note records the provider-facing scope reviewed from the current open OmniR

- OmniRoute PR #6410: added `hcnsec`, the Huancheng Public API, as an OpenAI-compatible API-key provider. DurinDoor keeps it live-catalog first with `modelsFetcher`, `passthroughModels`, and no static seed models.
- OmniRoute PR #6311: API-key connection creation now defaults to the first unused name in the `main`, `main-2`, `main-3`, ... sequence based on all existing API-key connection names, not the current provider's raw connection count. DurinDoor still keeps the backend `authType + name` upsert behavior, but the add-connection modal no longer silently reuses an existing global API-key default name, and connection-list refreshes while the modal is open do not wipe in-progress input.
- OmniRoute PR #6499: reopening the add-key modal after a successful add clears the previous API key and any provider-specific fields (Azure endpoint/deployment, account ID, region, Ollama host) so stale credentials cannot create a duplicate connection. Reset only happens on the closed→open transition; background refetches while the modal is open preserve all typed inputs.

## Already Covered

Expand Down
50 changes: 31 additions & 19 deletions src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
"use client";

import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { parseBulkApiKeyLine, requiresProviderAccountId } from "@/lib/providerAccountIds";
import { allocateBulkConnectionName, bulkUsedNameSet } from "./apiKeyConnectionName";
import {
allocateBulkConnectionName,
bulkUsedNameSet,
buildAddApiKeyModalReset,
createAddApiKeyModalInitialState,
} from "./apiKeyConnectionName";

const BULK_PLACEHOLDER = `name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named`;

export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onBulkDone, onClose }) {
export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, existingConnectionNames, error, onSave, onBulkDone, onClose }) {
const NONE_PROXY_POOL_VALUE = "__none__";
const isOllamaLocal = provider === "ollama-local";
const isCookie = authType === "cookie";
Expand All @@ -26,22 +31,11 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const providerRegions = AI_PROVIDERS?.[provider]?.regions || null;
const defaultRegion = AI_PROVIDERS?.[provider]?.defaultRegion || providerRegions?.[0]?.id || "";

const [formData, setFormData] = useState({
name: "",
apiKey: "",
defaultModel: "",
priority: 1,
proxyPoolId: NONE_PROXY_POOL_VALUE,
ollamaHostUrl: "",
});
const [azureData, setAzureData] = useState({
azureEndpoint: "",
apiVersion: "2024-10-01-preview",
deployment: "",
organization: "",
});
const [accountIdData, setAccountIdData] = useState({ accountId: "" });
const [region, setRegion] = useState(defaultRegion);
const initialState = createAddApiKeyModalInitialState(existingConnectionNames, defaultRegion);
const [formData, setFormData] = useState(initialState.formData);
const [azureData, setAzureData] = useState(initialState.azureData);
const [accountIdData, setAccountIdData] = useState(initialState.accountIdData);
const [region, setRegion] = useState(initialState.region);
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [saving, setSaving] = useState(false);
Expand All @@ -53,6 +47,23 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const [bulkText, setBulkText] = useState("");
const [bulkResult, setBulkResult] = useState(null); // { success, failed }

// #6499 — on closed→open, pre-fill a unique default name and reset all
// credential/provider-specific fields so stale secrets cannot create a
// duplicate connection. Background refetches while the modal is open do not
// trigger this reset, so typed inputs are preserved. The API still enforces
// create-only (409); this is UX.
const wasOpenRef = useRef(false);
useEffect(() => {
const wasOpen = wasOpenRef.current;
wasOpenRef.current = isOpen;
const reset = buildAddApiKeyModalReset(wasOpen, isOpen, existingConnectionNames, defaultRegion);
if (!reset) return;
setFormData(reset.formData);
setAzureData(reset.azureData);
setAccountIdData(reset.accountIdData);
setRegion(reset.region);
}, [isOpen, existingConnectionNames, defaultRegion]);

const buildProviderSpecificData = () => {
if (isOllamaLocal && formData.ollamaHostUrl.trim()) {
return { baseUrl: formData.ollamaHostUrl.trim() };
Expand Down Expand Up @@ -452,6 +463,7 @@ AddApiKeyModal.propTypes = {
name: PropTypes.string,
})),
error: PropTypes.string,
existingConnectionNames: PropTypes.arrayOf(PropTypes.string),
onSave: PropTypes.func.isRequired,
onBulkDone: PropTypes.func,
onClose: PropTypes.func.isRequired,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,32 @@ export function defaultApiKeyConnectionName(existingConnectionNames = []) {
}
}

export function createAddApiKeyModalInitialState(existingConnectionNames = [], defaultRegion = "") {
return {
formData: {
name: defaultApiKeyConnectionName(existingConnectionNames),
apiKey: "",
defaultModel: "",
priority: 1,
proxyPoolId: "__none__",
ollamaHostUrl: "",
},
azureData: {
azureEndpoint: "",
apiVersion: "2024-10-01-preview",
deployment: "",
organization: "",
},
accountIdData: { accountId: "" },
region: defaultRegion,
};
}

export function buildAddApiKeyModalReset(wasOpen, isOpen, existingConnectionNames = [], defaultRegion = "") {
if (!shouldResetAddApiKeyModal(wasOpen, isOpen)) return null;
return createAddApiKeyModalInitialState(existingConnectionNames, defaultRegion);
}

export function shouldResetAddApiKeyModal(previousIsOpen, nextIsOpen) {
return !previousIsOpen && nextIsOpen;
}
Expand Down
11 changes: 7 additions & 4 deletions src/app/(dashboard)/dashboard/providers/[id]/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default function ProviderDetailPage() {
}, [providerId]);
const { getCaps } = useModelCaps();
const [connections, setConnections] = useState([]);
const [globalApiKeyConnectionNames, setGlobalApiKeyConnectionNames] = useState([]);
const [providerApiKeyConnectionNames, setProviderApiKeyConnectionNames] = useState([]);
const [loading, setLoading] = useState(true);
const [providerNode, setProviderNode] = useState(null);
const [proxyPools, setProxyPools] = useState([]);
Expand Down Expand Up @@ -353,7 +353,11 @@ export default function ProviderDetailPage() {
const allConnections = connectionsData.connections || [];
const filtered = allConnections.filter(c => c.provider === providerId);
setConnections(filtered);
setGlobalApiKeyConnectionNames(apiKeyConnectionNames(allConnections));
// #6499 — the name-based collision scope in createProviderConnection is
// (provider, authType=apikey, name): provider-local. Derive default-name
// candidates from THIS provider's apikey connections only; using global
// names would pointlessly skip "main" just because another provider took it.
setProviderApiKeyConnectionNames(apiKeyConnectionNames(filtered));
}
if (proxyPoolsRes.ok) {
setProxyPools(proxyPoolsData.proxyPools || []);
Expand Down Expand Up @@ -1954,8 +1958,7 @@ export default function ProviderDetailPage() {
authHint={providerInfo?.authHint}
website={providerInfo?.website}
proxyPools={proxyPools}
existingConnectionNames={globalApiKeyConnectionNames}
existingConnectionCount={connections.length}
existingConnectionNames={providerApiKeyConnectionNames}
error={addConnectionError}
onSave={handleSaveApiKey}
onBulkDone={fetchConnections}
Expand Down
43 changes: 28 additions & 15 deletions src/app/api/providers/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ export async function POST(request) {
if (!apiKey && provider !== "ollama-local" && !isNoAuthProvider) {
return NextResponse.json({ error: `${isWebCookieProvider ? "Cookie value" : "API Key"} is required` }, { status: 400 });
}
const connectionName = name || displayName || AI_PROVIDERS[provider]?.name;
const rawConnectionName = name || displayName || AI_PROVIDERS[provider]?.name;
const connectionName = typeof rawConnectionName === "string" ? rawConnectionName.trim() : "";
if (!connectionName) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
Expand Down Expand Up @@ -207,20 +208,32 @@ export async function POST(request) {
}

// Bulk add sends createOnly so a name collision never silently overwrites
// an existing key — the repo throws PROVIDER_CONNECTION_NAME_CONFLICT and
// we surface a 409 instead of the default upsert.
const newConnection = await createProviderConnection({
provider,
authType: isWebCookieProvider ? "cookie" : "apikey",
name: connectionName,
apiKey: apiKey || "",
priority: priority || 1,
globalPriority: globalPriority || null,
defaultModel: defaultModel || null,
providerSpecificData: mergedProviderSpecificData,
isActive: true,
testStatus: testStatus || "unknown",
}, { requireNewName: createOnly === true });
// an existing key (requireNewName → PROVIDER_CONNECTION_NAME_CONFLICT → 409).
// #6499 — single dashboard add is always create-only: a duplicate
// (provider, apikey, name) must NOT silently upsert/overwrite
// (createOnly → PROVIDER_CONNECTION_ALREADY_EXISTS → 409). The repo throws
// atomically inside its transaction; the explicit update path is
// updateProviderConnection (PUT /api/providers/[id]).
let newConnection;
try {
newConnection = await createProviderConnection({
provider,
authType: isWebCookieProvider ? "cookie" : "apikey",
name: connectionName,
apiKey: apiKey || "",
priority: priority || 1,
globalPriority: globalPriority || null,
defaultModel: defaultModel || null,
providerSpecificData: mergedProviderSpecificData,
isActive: true,
testStatus: testStatus || "unknown",
}, createOnly === true ? { requireNewName: true } : { createOnly: true });
} catch (error) {
if (error?.code === "PROVIDER_CONNECTION_ALREADY_EXISTS" || error?.code === "PROVIDER_CONNECTION_NAME_CONFLICT") {
return NextResponse.json({ error: error.message, code: error.code }, { status: 409 });
}
throw error;
}

// Hide sensitive fields
const result = sanitizeProviderConnection(newConnection);
Expand Down
10 changes: 9 additions & 1 deletion src/lib/db/repos/connectionsRepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ function reorderInTx(db, providerId) {
});
}

export async function createProviderConnection(data, { shouldCommit, requireNewName } = {}) {
export async function createProviderConnection(data, { shouldCommit, requireNewName, createOnly = false } = {}) {
const db = await getAdapter();
// OAuth flows can be cancelled while an upstream exchange is in flight.
// Check after the async adapter lookup and immediately before the synchronous
Expand Down Expand Up @@ -243,6 +243,14 @@ export async function createProviderConnection(data, { shouldCommit, requireNewN
}

if (existing) {
// #6499 — create-only (dashboard "add API key"): a duplicate (provider,
// apikey, name) must error, never silently upsert/overwrite. The explicit
// update path is updateProviderConnection (PUT /api/providers/[id]).
if (createOnly && data.authType === "apikey") {
const error = new Error(`A connection named "${data.name}" already exists for this provider`);
error.code = "PROVIDER_CONNECTION_ALREADY_EXISTS";
throw error;
}
const merged = { ...mergeProviderConnection(existing, data), updatedAt: now };
upsert(db, merged);
result = merged;
Expand Down
Loading
Loading