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
24 changes: 13 additions & 11 deletions docs/CLI-TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ Add to `~/.bashrc` (or `~/.zshrc`), then run `source ~/.bashrc`:
# OmniRoute Universal Endpoint
export OPENAI_BASE_URL="http://localhost:20128/v1"
export OPENAI_API_KEY="sk-your-omniroute-key"
export ANTHROPIC_BASE_URL="http://localhost:20128/v1"
export ANTHROPIC_API_KEY="sk-your-omniroute-key"
export ANTHROPIC_BASE_URL="http://localhost:20128"
export ANTHROPIC_AUTH_TOKEN="sk-your-omniroute-key"
export GEMINI_BASE_URL="http://localhost:20128/v1"
export GEMINI_API_KEY="sk-your-omniroute-key"
```
Expand All @@ -137,18 +137,19 @@ export GEMINI_API_KEY="sk-your-omniroute-key"
### Claude Code

```bash
# Via CLI:
claude config set --global api-base-url http://localhost:20128/v1

# Or create ~/.claude/settings.json:
# Create ~/.claude/settings.json:
mkdir -p ~/.claude && cat > ~/.claude/settings.json << EOF
{
"apiBaseUrl": "http://localhost:20128/v1",
"apiKey": "sk-your-omniroute-key"
"env": {
"ANTHROPIC_BASE_URL": "http://localhost:20128",
"ANTHROPIC_AUTH_TOKEN": "sk-your-omniroute-key"
}
}
EOF
```

Use the unified Anthropic gateway root for Claude Code. Do not append `/v1` here.

**Test:** `claude "say hello"`

---
Expand Down Expand Up @@ -370,6 +371,7 @@ They run as internal routes and use OmniRoute's model routing automatically.
```bash
# Install all CLIs and configure for OmniRoute (replace with your key and server URL)
OMNIROUTE_URL="http://localhost:20128/v1"
OMNIROUTE_ANTHROPIC_URL="http://localhost:20128"
OMNIROUTE_KEY="sk-your-omniroute-key"

npm install -g @anthropic-ai/claude-code @openai/codex opencode-ai cline kilocode @qwen-code/qwen-code
Expand All @@ -380,13 +382,13 @@ apt-get install -y unzip 2>/dev/null; curl -fsSL https://cli.kiro.dev/install |
# Write configs
mkdir -p ~/.claude ~/.codex ~/.config/opencode ~/.continue

cat > ~/.claude/settings.json <<< "{\"apiBaseUrl\":\"$OMNIROUTE_URL\",\"apiKey\":\"$OMNIROUTE_KEY\"}"
cat > ~/.claude/settings.json <<< "{\"env\":{\"ANTHROPIC_BASE_URL\":\"$OMNIROUTE_ANTHROPIC_URL\",\"ANTHROPIC_AUTH_TOKEN\":\"$OMNIROUTE_KEY\"}}"
cat > ~/.codex/config.yaml <<< "model: auto\napiKey: $OMNIROUTE_KEY\napiBaseUrl: $OMNIROUTE_URL"
cat >> ~/.bashrc << EOF
export OPENAI_BASE_URL="$OMNIROUTE_URL"
export OPENAI_API_KEY="$OMNIROUTE_KEY"
export ANTHROPIC_BASE_URL="$OMNIROUTE_URL"
export ANTHROPIC_API_KEY="$OMNIROUTE_KEY"
export ANTHROPIC_BASE_URL="$OMNIROUTE_ANTHROPIC_URL"
export ANTHROPIC_AUTH_TOKEN="$OMNIROUTE_KEY"
EOF

source ~/.bashrc
Expand Down
10 changes: 7 additions & 3 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,15 +272,19 @@ Settings → Models → Advanced:

### Claude Code

Edit `~/.claude/config.json`:
Edit `~/.claude/settings.json`:

```json
{
"anthropic_api_base": "http://localhost:20128/v1",
"anthropic_api_key": "your-omniroute-api-key"
"env": {
"ANTHROPIC_BASE_URL": "http://localhost:20128",
"ANTHROPIC_AUTH_TOKEN": "your-omniroute-api-key"
}
}
```

Use the Claude-compatible root endpoint here. Do not append `/v1` to `ANTHROPIC_BASE_URL`.

### Codex CLI

```bash
Expand Down
109 changes: 89 additions & 20 deletions open-sse/handlers/imageGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,21 @@ const STABILITY_EDIT_ENDPOINTS = {

const STABILITY_CONTROL_MODELS = new Set(["sketch", "structure", "style", "style-transfer"]);

function appendOptionalFormValue(formData, key, value) {
if (value === undefined || value === null || value === "") return;
formData.append(key, String(value));
}

function appendImageFormValue(formData, key, source, filename) {
formData.append(
key,
new Blob([source.buffer], {
type: source.contentType || "application/octet-stream",
}),
filename
);
}

const FAL_PRESET_SIZES = {
"1024x1024": "square_hd",
"512x512": "square",
Expand Down Expand Up @@ -1054,52 +1069,107 @@ async function handleStabilityAIImageGeneration({
? normalizeRequestedImageFormat(body, "png", ["png", "webp"])
: normalizeRequestedImageFormat(body, "png"),
};
const formData = new FormData();

if (body.prompt) upstreamBody.prompt = body.prompt;
if (body.negative_prompt) upstreamBody.negative_prompt = body.negative_prompt;
if (body.seed !== undefined) upstreamBody.seed = body.seed;
appendOptionalFormValue(formData, "output_format", upstreamBody.output_format);
if (body.prompt) {
upstreamBody.prompt = body.prompt;
appendOptionalFormValue(formData, "prompt", body.prompt);
}
if (body.negative_prompt) {
upstreamBody.negative_prompt = body.negative_prompt;
appendOptionalFormValue(formData, "negative_prompt", body.negative_prompt);
}
if (body.seed !== undefined) {
upstreamBody.seed = body.seed;
appendOptionalFormValue(formData, "seed", body.seed);
}

try {
if (STABILITY_GENERATION_ENDPOINTS[model]) {
if (model.startsWith("sd3.5")) {
upstreamBody.model = model;
appendOptionalFormValue(formData, "model", model);
}

if (imageUrl) {
const imageSource = await resolveImageSource(imageUrl);
upstreamBody.mode = "image-to-image";
upstreamBody.image = (await resolveImageSource(imageUrl)).base64;
if (body.strength !== undefined) upstreamBody.strength = body.strength;
appendOptionalFormValue(formData, "mode", "image-to-image");
upstreamBody.image = imageSource.base64;
appendImageFormValue(formData, "image", imageSource, "image");
if (body.strength !== undefined) {
upstreamBody.strength = body.strength;
appendOptionalFormValue(formData, "strength", body.strength);
}
} else {
upstreamBody.mode = "text-to-image";
appendOptionalFormValue(formData, "mode", "text-to-image");
}

if (!model.startsWith("sd3.5") || !imageUrl) {
upstreamBody.aspect_ratio = body.aspect_ratio || mapImageSize(body.size);
const aspectRatio = body.aspect_ratio || mapImageSize(body.size);
upstreamBody.aspect_ratio = aspectRatio;
appendOptionalFormValue(formData, "aspect_ratio", aspectRatio);
}

if (body.style_preset) upstreamBody.style_preset = body.style_preset;
if (body.style_preset) {
upstreamBody.style_preset = body.style_preset;
appendOptionalFormValue(formData, "style_preset", body.style_preset);
}
} else {
if (imageUrl) {
upstreamBody.image = (await resolveImageSource(imageUrl)).base64;
const imageSource = await resolveImageSource(imageUrl);
upstreamBody.image = imageSource.base64;
appendImageFormValue(formData, "image", imageSource, "image");
}

if (maskUrl && shouldIncludeStabilityMask(model)) {
upstreamBody.mask = (await resolveImageSource(maskUrl)).base64;
const maskSource = await resolveImageSource(maskUrl);
upstreamBody.mask = maskSource.base64;
appendImageFormValue(formData, "mask", maskSource, "mask");
}

if (body.search_prompt) upstreamBody.search_prompt = body.search_prompt;
if (body.grow_mask !== undefined) upstreamBody.grow_mask = body.grow_mask;
if (body.control_strength !== undefined)
if (body.search_prompt) {
upstreamBody.search_prompt = body.search_prompt;
appendOptionalFormValue(formData, "search_prompt", body.search_prompt);
}
if (body.grow_mask !== undefined) {
upstreamBody.grow_mask = body.grow_mask;
appendOptionalFormValue(formData, "grow_mask", body.grow_mask);
}
if (body.control_strength !== undefined) {
upstreamBody.control_strength = body.control_strength;
if (body.creativity !== undefined) upstreamBody.creativity = body.creativity;
if (body.left !== undefined) upstreamBody.left = body.left;
if (body.right !== undefined) upstreamBody.right = body.right;
if (body.up !== undefined) upstreamBody.up = body.up;
if (body.down !== undefined) upstreamBody.down = body.down;
if (body.style_preset) upstreamBody.style_preset = body.style_preset;
appendOptionalFormValue(formData, "control_strength", body.control_strength);
}
if (body.creativity !== undefined) {
upstreamBody.creativity = body.creativity;
appendOptionalFormValue(formData, "creativity", body.creativity);
}
if (body.left !== undefined) {
upstreamBody.left = body.left;
appendOptionalFormValue(formData, "left", body.left);
}
if (body.right !== undefined) {
upstreamBody.right = body.right;
appendOptionalFormValue(formData, "right", body.right);
}
if (body.up !== undefined) {
upstreamBody.up = body.up;
appendOptionalFormValue(formData, "up", body.up);
}
if (body.down !== undefined) {
upstreamBody.down = body.down;
appendOptionalFormValue(formData, "down", body.down);
}
if (body.style_preset) {
upstreamBody.style_preset = body.style_preset;
appendOptionalFormValue(formData, "style_preset", body.style_preset);
}

if (STABILITY_CONTROL_MODELS.has(model) && !upstreamBody.prompt) {
upstreamBody.prompt = body.prompt || "";
appendOptionalFormValue(formData, "prompt", body.prompt || "");
}
}

Expand All @@ -1111,11 +1181,10 @@ async function handleStabilityAIImageGeneration({
const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(upstreamBody),
body: formData,
});

if (!response.ok) {
Expand Down
29 changes: 27 additions & 2 deletions src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,35 @@ function getVoiceList(providerId: string) {

/** Parse a human-readable error from the API error response */
function parseApiError(raw: any, statusCode: number): { message: string; isCredentials: boolean } {
const readErrorMessage = (value: any): string | null => {
if (!value) return null;
if (typeof value === "string") return value;
if (Array.isArray(value)) {
const messages = value
.map((entry: any) => readErrorMessage(entry))
.filter((entry: string | null): entry is string => Boolean(entry));
if (messages.length > 0) return messages.join(", ");
return null;
}
if (typeof value.message === "string") return value.message;
if (typeof value.detail === "string") return value.detail;
if (Array.isArray(value.errors)) {
const messages = value.errors
.map((entry: any) => readErrorMessage(entry))
.filter((entry: string | null): entry is string => Boolean(entry));
if (messages.length > 0) return messages.join(", ");
}
try {
return JSON.stringify(value);
} catch {
return null;
}
};

const msg =
raw?.error?.message ||
readErrorMessage(raw?.error) ||
readErrorMessage(raw?.errors) ||
raw?.err_msg ||
raw?.error ||
raw?.message ||
raw?.detail ||
(typeof raw === "string" ? raw : null) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
import {
getStoredClaudeAuthValue,
normalizeClaudeBaseUrl,
} from "@/shared/services/claudeCliConfig";

const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;

Expand Down Expand Up @@ -98,7 +102,7 @@ export default function ClaudeToolCard({
}
});
// Restore selected key from file: match token stored in file against known keys
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
const tokenFromFile = getStoredClaudeAuthValue(env);
if (tokenFromFile) {
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
// Mask the token from file to compare against the masked list.
Expand All @@ -124,12 +128,12 @@ export default function ClaudeToolCard({

const getEffectiveBaseUrl = () => {
const url = customBaseUrl || baseUrl;
return url.endsWith("/v1") ? url : `${url}/v1`;
return normalizeClaudeBaseUrl(url);
};

const getDisplayUrl = () => {
const url = customBaseUrl || baseUrl;
return url.endsWith("/v1") ? url : `${url}/v1`;
return normalizeClaudeBaseUrl(url);
};

const handleApplySettings = async () => {
Expand All @@ -139,13 +143,9 @@ export default function ClaudeToolCard({
const env: any = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };

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

Using any bypasses TypeScript's type checking. Since this object is intended to store environment variable mappings (strings), it should be typed as Record<string, string>.

Suggested change
const env: any = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
const env: Record<string, string> = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };


// (#523) Prefer keyId lookup so the backend writes the real key to disk.
// Fall back to sk_omniroute for localhost-only setups without a key.
// If no key is selected, leave auth unset so local installs can rely on
// anonymous access instead of persisting a fake placeholder token.
const selectedKeyId = selectedApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
const skOmnirouteFallback = !cloudEnabled ? "sk_omniroute" : null;

if (!selectedKeyId && skOmnirouteFallback) {
env.ANTHROPIC_AUTH_TOKEN = skOmnirouteFallback;
}

tool.defaultModels.forEach((model) => {
const targetModel = modelMappings[model.alias] || model.defaultValue || "";
Expand Down Expand Up @@ -221,13 +221,13 @@ export default function ClaudeToolCard({

// Generate settings.json content for manual copy
const getManualConfigs = () => {
const keyToUse =
selectedApiKey && selectedApiKey.trim()
? selectedApiKey
: !cloudEnabled
? "sk_omniroute"
: "<API_KEY_FROM_DASHBOARD>";
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl(), ANTHROPIC_AUTH_TOKEN: keyToUse };
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };

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 env object is implicitly typed as { ANTHROPIC_BASE_URL: string }, which will cause TypeScript errors when attempting to add the ANTHROPIC_AUTH_TOKEN property or other dynamic keys from tool.defaultModels. Explicitly typing it as Record<string, string> resolves this.

Suggested change
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
const env: Record<string, string> = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };

if (selectedApiKey && selectedApiKey.trim()) {
env.ANTHROPIC_AUTH_TOKEN = "<API_KEY_FROM_DASHBOARD>";
} else if (cloudEnabled) {
env.ANTHROPIC_AUTH_TOKEN = "<API_KEY_FROM_DASHBOARD>";
}

tool.defaultModels.forEach((model) => {
const targetModel = modelMappings[model.alias];
if (targetModel && model.envKey) env[model.envKey] = targetModel;
Expand Down Expand Up @@ -443,7 +443,7 @@ export default function ClaudeToolCard({
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
{cloudEnabled ? t("noApiKeysCreateOne") : t("noApiKeysAvailable")}
</span>
)}
</div>
Expand Down
Loading