Skip to content
Open
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
40 changes: 40 additions & 0 deletions gitbook/content/en/features/smart-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,46 @@ User request → cc/claude-opus-4-5

---

## Account Pinning per Request

When a provider has multiple connected accounts (e.g. several Antigravity or Codex accounts), 9Router normally picks one automatically (fill-first or round-robin, see [Configuration Options](#configuration-options)). Chat requests can opt into **pinning** a specific account with the `x-connection-id` header — the same opt-in already supported by the image and video endpoints:

```bash
curl https://your-9router/v1/chat/completions \
-H "Content-Type: application/json" \
-H "x-connection-id: <providerConnection id>" \
-d '{
"model": "antigravity/gemini-3.8-flash-medium",
"messages": [{"role": "user", "content": "hi"}]
}'
```

How pinning behaves:

- **Priority, not exclusivity.** The pinned account is tried first. If it is unavailable — rate-limited, out of quota, or erroring — the request automatically falls back to the other accounts of the same provider, exactly like unpinned traffic.
- **Where to find the id.** Open the provider's page in the dashboard and click the `xxxxxxxx…` ID chip next to any connection to copy its connection id, or call `GET /api/providers`.
- **Safe by default.** Omit the header and nothing changes. An unknown, inactive, or already-exhausted id is ignored and routing proceeds normally.
- **Works across chat paths.** Single-model requests, combos, fusion, and capacity-adapter fallbacks all honor the header, because pinning is applied at the account-selection layer — orthogonal to per-API-key access control, which gates models, not accounts.

**Multi-agent teams:** give each agent its own `x-connection-id` so it burns its "own" account first and only borrows the shared pool when that account is exhausted.

**Claude Code (CLI & VS Code extension):** custom headers are set via the `ANTHROPIC_CUSTOM_HEADERS` environment variable. Add it to the `env` block of `~/.claude/settings.json` (project-level `.claude/settings.json` overrides it — handy for pinning different accounts per project/agent):

```json
{
"env": {
"ANTHROPIC_BASE_URL": "https://your-9router/v1",
"ANTHROPIC_AUTH_TOKEN": "sk-...",
"ANTHROPIC_CUSTOM_HEADERS": "x-connection-id: <providerConnection id>",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "antigravity/gemini-3.8-flash-medium"
}
}
```

Multiple headers are comma-separated: `"Name1:Value1,Name2:Value2"`.

---

## Model Selection Logic

9Router selects the best model based on:
Expand Down
16 changes: 16 additions & 0 deletions gitbook/content/en/integration/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ Claude Code stores its configuration in `~/.claude/settings.json`. You can manua
}
```

### Pin a specific account (optional)

When a 9Router provider has multiple connected accounts, you can pin Claude Code to a specific one with the `x-connection-id` header. Claude Code sends custom headers via the `ANTHROPIC_CUSTOM_HEADERS` environment variable — put it in the `env` block of `~/.claude/settings.json` (or a project-level `.claude/settings.json` to pin a different account per project):

```json
{
"env": {
"ANTHROPIC_BASE_URL": "http://localhost:20128/v1",
"ANTHROPIC_AUTH_TOKEN": "sk-...",
"ANTHROPIC_CUSTOM_HEADERS": "x-connection-id: <providerConnection id>"
}
}
```

Copy the connection id from the provider page in the 9Router dashboard (the `xxxxxxxx…` chip on each connection). If the pinned account is unavailable — rate-limited or out of quota — 9Router automatically falls back to the provider's other accounts. See [Smart Routing](features/smart-routing.md) for details.

## Troubleshooting

### Connection Issues
Expand Down
4 changes: 4 additions & 0 deletions open-sse/services/combo.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ export function detectRequiredCapabilities(body) {
if (fmime) addByMime(fmime);
else required.add("pdf");
}
// Claude tool_result blocks can carry images in their nested content[];
// the claude→openai translator turns those into image_url parts, so they
// must count toward the request's required capabilities too.
if (t === "tool_result" && Array.isArray(b.content)) for (const c of b.content) scanBlock(c);
// gemini parts: inlineData/fileData carry a mime
addByMime(b.inlineData?.mimeType || b.fileData?.mimeType);
};
Expand Down
15 changes: 15 additions & 0 deletions open-sse/translator/concerns/modality.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ function filterBlocks(blocks, capOf, caps, removed, isLast) {
return out;
}

// Claude tool_result blocks keep their media in a nested content[]; filterBlocks
// only inspects top-level block types, so drop nested media here and keep the
// tool_result block itself (its text) intact.
function stripNestedToolResultMedia(blocks, caps, isLast) {
for (const block of blocks) {
if (block?.type !== "tool_result" || !Array.isArray(block.content)) continue;
block.content = block.content.filter((c) => {
const cap = capForClaudeBlock(c);
return !(cap && caps[cap] === false);
});
if (block.content.length === 0) block.content = [{ type: "text", text: ph("vision", isLast) }];
}
}

// OpenAI / OpenAI-compatible chat messages[].content[].
function stripOpenAI(body, caps) {
if (!Array.isArray(body.messages)) return;
Expand Down Expand Up @@ -89,6 +103,7 @@ function stripClaude(body, caps) {
if (!Array.isArray(msg.content)) return;
const removed = new Set();
msg.content = filterBlocks(msg.content, capForClaudeBlock, caps, removed, i === last);
stripNestedToolResultMedia(msg.content, caps, i === last);
});
}

Expand Down
12 changes: 12 additions & 0 deletions src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import { createPortal } from "react-dom";
import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
import PropTypes from "prop-types";
import { Badge, Toggle, Tooltip } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import CooldownTimer from "./CooldownTimer";

export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) {
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
const [updatingProxy, setUpdatingProxy] = useState(false);
const proxyDropdownRef = useRef(null);
const { copied, copy } = useCopyToClipboard();
const copyIdKey = `conn-id-${connection.id}`;
const proxyBtnRef = useRef(null);
const [proxyDropdownStyle, setProxyDropdownStyle] = useState(null);

Expand Down Expand Up @@ -228,6 +231,15 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
</span>
)}
<span className="text-xs text-text-muted">#{connection.priority}</span>
<button
type="button"
onClick={() => copy(connection.id || "", copyIdKey)}
title="Copy connection ID — send it as the x-connection-id request header to pin requests to this account (falls back to other accounts when it is unavailable)"
className="flex items-center gap-0.5 rounded font-mono text-[11px] text-text-muted transition-colors hover:text-primary"
>
<span className="material-symbols-outlined text-xs">{copied === copyIdKey ? "check" : "content_copy"}</span>
{copied === copyIdKey ? "copied" : `${(connection.id || "").slice(0, 8)}…`}
</button>
{connection.globalPriority && (
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
)}
Expand Down
3 changes: 3 additions & 0 deletions src/app/(dashboard)/dashboard/providers/[id]/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -1594,6 +1594,9 @@ export default function ProviderDetailPage() {
</div>
</div>
</div>
<p className="mb-4 -mt-1 text-xs text-text-muted">
Tip: pin requests to a specific account by sending the <code>x-connection-id</code> request header with a connection ID — click the ID chip on a connection to copy it. When the pinned account is unavailable, 9Router falls back to the other accounts automatically.
</p>

{connections.length === 0 ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
Expand Down
6 changes: 5 additions & 1 deletion src/sse/handlers/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ export async function handleChat(request, clientRawRequest = null) {
* Handle single model chat request
*/
async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null, requestedModel = null) {
// Optional account pinning, same opt-in header as the image/video endpoints:
// `x-connection-id: <providerConnection id>`. Falls back to the normal
// account strategy when the header is absent or the id is unknown/unavailable.
const preferredConnectionId = request?.headers?.get("x-connection-id") || null;
const modelInfo = await getModelInfo(modelStr);

// If provider is null, this might be a combo name - check and handle
Expand Down Expand Up @@ -277,7 +281,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
let lastHeaders = null;

while (true) {
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { requestedModel: requestedModel || model });
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { requestedModel: requestedModel || model, preferredConnectionId });

// All accounts unavailable
if (!credentials || credentials.allRateLimited) {
Expand Down