Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ecc1908c7 | |||
| 6a2c7b467d | |||
| 0acef57865 | |||
| 43046ee649 | |||
| a15fda0c08 | |||
| e5988764ce | |||
| 9c9d9b5a8d | |||
| 44dc564d85 | |||
| 83e367afab | |||
| 8b7e7c2669 | |||
| 53474021b7 | |||
| da1ed1b5b2 | |||
| e08d661600 | |||
| 1aa1bc7a26 | |||
| 47634e942e | |||
| 15466cbf1a | |||
| 2a749db427 | |||
| ecccce86e4 |
@@ -4,6 +4,47 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.1] - 2026-03-22
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **#521** — Login no longer gets stuck after skipping password setup (redirects to onboarding)
|
||||
- **#522** — API Manager: Removed misleading "Copy masked key" button (replaced with lock icon tooltip)
|
||||
- **#527** — Claude Code + Codex superpowers loop: `tool_result` blocks now converted to text instead of dropped
|
||||
- **#532** — OpenCode GO API key validation now uses the correct `zen/v1` endpoint (`testKeyBaseUrl`)
|
||||
- **#489** — Antigravity: missing `googleProjectId` returns structured 422 error with reconnect guidance
|
||||
- **#510** — Windows: MSYS2/Git-Bash paths (`/c/Program Files/...`) are now normalized to `C:\\Program Files\\...`
|
||||
- **#492** — `omniroute` CLI now detects `mise`/`nvm` when `app/server.js` is missing and shows targeted fix
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- **#513** — Docker password reset: `INITIAL_PASSWORD` env var workaround documented
|
||||
- **#520** — pnpm: `pnpm approve-builds better-sqlite3` documented
|
||||
|
||||
### ✅ Closed Issues
|
||||
|
||||
#489, #492, #510, #513, #520, #521, #522, #525, #527, #532
|
||||
|
||||
---
|
||||
|
||||
## [2.9.5] — 2026-03-22
|
||||
|
||||
> Sprint: New OpenCode providers, embedding credentials fix, CLI masked key bug, CACHE_TAG_PATTERN fix.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **CLI tools save masked API key to config files** — `claude-settings`, `cline-settings`, and `openclaw-settings` POST routes now accept a `keyId` param and resolve the real API key from DB before writing to disk. `ClaudeToolCard` updated to send `keyId` instead of the masked display string. Fixes #523, #526.
|
||||
- **Custom embedding providers: `No credentials` error** — `/v1/embeddings` now tracks `credentialsProviderId` separately from the routing prefix, so credentials are fetched from the matching provider node ID rather than the public prefix string. Fixes a regression where `google/gemini-embedding-001` and similar custom-provider models would always fail with a credentials error. Fixes #532-related. (PR #528 by @jacob2826)
|
||||
- **Context cache protection regex misses `\n` prefix** — `CACHE_TAG_PATTERN` in `comboAgentMiddleware.ts` updated to match both literal `\n` (backslash-n) and actual newline U+000A that `combo.ts` streaming injects around the `<omniModel>` tag after fix #515. Fixes #531.
|
||||
|
||||
### ✨ New Providers
|
||||
|
||||
- **OpenCode Zen** — Free tier gateway at `opencode.ai/zen/v1` with 3 models: `minimax-m2.5-free`, `big-pickle`, `gpt-5-nano`
|
||||
- **OpenCode Go** — Subscription service at `opencode.ai/zen/go/v1` with 4 models: `glm-5`, `kimi-k2.5`, `minimax-m2.7` (Claude format), `minimax-m2.5` (Claude format)
|
||||
- Both providers use the new `OpencodeExecutor` which routes dynamically to `/chat/completions`, `/messages`, `/responses`, or `/models/{model}:generateContent` based on the requested model. (PR #530 by @kang-heewon)
|
||||
|
||||
---
|
||||
|
||||
## [2.9.4] — 2026-03-21
|
||||
|
||||
> Sprint: Bug fixes — preserve Codex prompt cache key, fix tagContent JSON escaping, sync expired token status to DB.
|
||||
|
||||
+21
-2
@@ -189,8 +189,27 @@ const serverJs = join(APP_DIR, "server.js");
|
||||
|
||||
if (!existsSync(serverJs)) {
|
||||
console.error("\x1b[31m✖ Server not found at:\x1b[0m", serverJs);
|
||||
console.error(" This usually means the package was not built correctly.");
|
||||
console.error(" Try reinstalling: npm install -g omniroute");
|
||||
console.error(" The package may not have been built correctly.");
|
||||
console.error("");
|
||||
// (#492) Detect common non-standard Node managers that cause this issue
|
||||
const nodeExec = process.execPath || "";
|
||||
const isMise = nodeExec.includes("mise") || nodeExec.includes(".local/share/mise");
|
||||
const isNvm = nodeExec.includes(".nvm") || nodeExec.includes("nvm");
|
||||
if (isMise) {
|
||||
console.error(
|
||||
" \x1b[33m⚠ mise detected:\x1b[0m If you installed via `npm install -g omniroute`,"
|
||||
);
|
||||
console.error(" try: \x1b[36mnpx omniroute@latest\x1b[0m (downloads a fresh copy)");
|
||||
console.error(" or: \x1b[36mmise exec -- npx omniroute\x1b[0m");
|
||||
} else if (isNvm) {
|
||||
console.error(
|
||||
" \x1b[33m⚠ nvm detected:\x1b[0m Try reinstalling after loading the correct Node version:"
|
||||
);
|
||||
console.error(" \x1b[36mnvm use --lts && npm install -g omniroute\x1b[0m");
|
||||
} else {
|
||||
console.error(" Try: \x1b[36mnpm install -g omniroute\x1b[0m (reinstall)");
|
||||
console.error(" Or: \x1b[36mnpx omniroute@latest\x1b[0m");
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.9.4
|
||||
version: 3.0.0-rc.1
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface EmbeddingProvider {
|
||||
}
|
||||
|
||||
export interface EmbeddingProviderNodeRow {
|
||||
id?: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface RegistryEntry {
|
||||
executor: string;
|
||||
baseUrl?: string;
|
||||
baseUrls?: string[];
|
||||
/** Override base URL used only for API key validation (e.g., opencode-go validates on zen/v1) */
|
||||
testKeyBaseUrl?: string;
|
||||
responsesBaseUrl?: string;
|
||||
urlSuffix?: string;
|
||||
urlBuilder?: (base: string, model: string, stream: boolean) => string;
|
||||
@@ -495,6 +497,41 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
"opencode-go": {
|
||||
id: "opencode-go",
|
||||
alias: "opencode-go",
|
||||
format: "openai",
|
||||
executor: "opencode",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
// (#532) Key validation must hit the main zen endpoint (same key works for both tiers)
|
||||
testKeyBaseUrl: "https://opencode.ai/zen/v1",
|
||||
authType: "apikey",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer",
|
||||
models: [
|
||||
{ id: "glm-5", name: "GLM-5" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
|
||||
],
|
||||
},
|
||||
|
||||
"opencode-zen": {
|
||||
id: "opencode-zen",
|
||||
alias: "opencode-zen",
|
||||
format: "openai",
|
||||
executor: "opencode",
|
||||
baseUrl: "https://opencode.ai/zen/v1",
|
||||
authType: "apikey",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer",
|
||||
models: [
|
||||
{ id: "minimax-m2.5-free", name: "MiniMax M2.5 Free" },
|
||||
{ id: "big-pickle", name: "Big Pickle" },
|
||||
{ id: "gpt-5-nano", name: "GPT 5 Nano" },
|
||||
],
|
||||
},
|
||||
|
||||
openrouter: {
|
||||
id: "openrouter",
|
||||
alias: "openrouter",
|
||||
|
||||
@@ -44,12 +44,28 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
// stale/wrong client-side values causing 404/403 from Cloud Code endpoints.
|
||||
// Opt-in escape hatch: set OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=1.
|
||||
const projectId =
|
||||
allowBodyProjectOverride && bodyProjectId ? bodyProjectId : credentialsProjectId || bodyProjectId;
|
||||
allowBodyProjectOverride && bodyProjectId
|
||||
? bodyProjectId
|
||||
: credentialsProjectId || bodyProjectId;
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"Missing Google projectId for Antigravity account. Please reconnect OAuth so OmniRoute can fetch your real Cloud Code project (loadCodeAssist)."
|
||||
);
|
||||
// (#489) Return a structured error instead of throwing — gives the client a clear signal
|
||||
// to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error".
|
||||
const errorMsg =
|
||||
"Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers → Antigravity so OmniRoute can fetch your Cloud Code project.";
|
||||
const errorBody = {
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: "oauth_missing_project_id",
|
||||
code: "missing_project_id",
|
||||
},
|
||||
};
|
||||
const resp = new Response(JSON.stringify(errorBody), {
|
||||
status: 422,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
// Returning a Response object signals the executor to stop and forward it
|
||||
return resp as unknown as never;
|
||||
}
|
||||
|
||||
// Fix contents for Claude models via Antigravity
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CursorExecutor } from "./cursor.ts";
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
import { PollinationsExecutor } from "./pollinations.ts";
|
||||
import { CloudflareAIExecutor } from "./cloudflare-ai.ts";
|
||||
import { OpencodeExecutor } from "./opencode.ts";
|
||||
|
||||
const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
@@ -22,6 +23,8 @@ const executors = {
|
||||
pol: new PollinationsExecutor(), // Alias
|
||||
"cloudflare-ai": new CloudflareAIExecutor(),
|
||||
cf: new CloudflareAIExecutor(), // Alias
|
||||
"opencode-zen": new OpencodeExecutor("opencode-zen"),
|
||||
"opencode-go": new OpencodeExecutor("opencode-go"),
|
||||
};
|
||||
|
||||
const defaultCache = new Map();
|
||||
@@ -47,3 +50,4 @@ export { CursorExecutor } from "./cursor.ts";
|
||||
export { DefaultExecutor } from "./default.ts";
|
||||
export { PollinationsExecutor } from "./pollinations.ts";
|
||||
export { CloudflareAIExecutor } from "./cloudflare-ai.ts";
|
||||
export { OpencodeExecutor } from "./opencode.ts";
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { getModelTargetFormat } from "../config/providerModels.ts";
|
||||
|
||||
export class OpencodeExecutor extends BaseExecutor {
|
||||
_requestFormat: string | null = null;
|
||||
|
||||
constructor(provider: string) {
|
||||
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai";
|
||||
try {
|
||||
return await super.execute(input);
|
||||
} finally {
|
||||
this._requestFormat = null;
|
||||
}
|
||||
}
|
||||
|
||||
buildUrl(
|
||||
model: string,
|
||||
stream: boolean,
|
||||
urlIndex = 0,
|
||||
credentials: ProviderCredentials | null = null
|
||||
) {
|
||||
void urlIndex;
|
||||
void credentials;
|
||||
|
||||
const base = this.config.baseUrl;
|
||||
switch (this._requestFormat) {
|
||||
case "claude":
|
||||
return `${base}/messages`;
|
||||
case "openai-responses":
|
||||
return `${base}/responses`;
|
||||
case "gemini":
|
||||
return `${base}/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
|
||||
default:
|
||||
return `${base}/chat/completions`;
|
||||
}
|
||||
}
|
||||
|
||||
buildHeaders(credentials: ProviderCredentials | null, stream = true) {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
|
||||
if (key) {
|
||||
headers["Authorization"] = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
if (this._requestFormat === "claude") {
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
@@ -308,6 +308,27 @@ export async function handleChatCore({
|
||||
}
|
||||
return [];
|
||||
}
|
||||
// (#527) tool_result → convert to text instead of dropping.
|
||||
// When Claude Code + superpowers routes through Codex, it sends tool_result
|
||||
// blocks in user messages. Silently dropping them causes Codex to loop
|
||||
// because it never receives the tool response and keeps re-requesting it.
|
||||
if (block.type === "tool_result") {
|
||||
const toolId = block.tool_use_id ?? block.id ?? "unknown";
|
||||
const resultContent = block.content ?? block.text ?? block.output ?? "";
|
||||
const resultText =
|
||||
typeof resultContent === "string"
|
||||
? resultContent
|
||||
: Array.isArray(resultContent)
|
||||
? resultContent
|
||||
.filter((c: Record<string, unknown>) => c.type === "text")
|
||||
.map((c: Record<string, unknown>) => c.text)
|
||||
.join("\n")
|
||||
: JSON.stringify(resultContent);
|
||||
if (resultText.length > 0) {
|
||||
return [{ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
// Unknown types: drop silently
|
||||
log?.debug?.("CONTENT", `Dropped unsupported content part type="${block.type}"`);
|
||||
return [];
|
||||
|
||||
@@ -34,7 +34,11 @@ interface Message {
|
||||
|
||||
// ── Context Caching Tag ─────────────────────────────────────────────────────
|
||||
|
||||
const CACHE_TAG_PATTERN = /<omniModel>([^<]+)<\/omniModel>/;
|
||||
// Handles both actual newlines (U+000A) and literal \n sequences injected
|
||||
// by combo.ts streaming around the <omniModel> tag (#531). Non-global so that
|
||||
// .exec() and .test() stay stateless; callers that need full replacement use
|
||||
// String.prototype.replace() which replaces all non-overlapping matches.
|
||||
const CACHE_TAG_PATTERN = /(?:\\n|\n)?<omniModel>([^<]+)<\/omniModel>(?:\\n|\n)?/;
|
||||
|
||||
/**
|
||||
* Inject the model tag into the last assistant message (or append a new one).
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.9.4",
|
||||
"version": "3.0.0-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.9.4",
|
||||
"version": "3.0.0-rc.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.9.4",
|
||||
"version": "3.0.0-rc.1",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -48,7 +48,8 @@ function extractChangelogSections(content) {
|
||||
}
|
||||
|
||||
function isSemver(value) {
|
||||
return /^\d+\.\d+\.\d+$/.test(value);
|
||||
// Accept X.Y.Z and X.Y.Z-prerelease.N (e.g. 3.0.0-rc.1, 3.0.0-beta.2)
|
||||
return /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(value);
|
||||
}
|
||||
|
||||
let hasFailure = false;
|
||||
|
||||
@@ -523,15 +523,12 @@ export default function ApiManagerPageClient() {
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center gap-1.5">
|
||||
<code className="text-sm text-text-muted font-mono truncate">{key.key}</code>
|
||||
<button
|
||||
onClick={() => copy(key.key, key.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all shrink-0"
|
||||
title={t("copyMaskedKey")}
|
||||
<span
|
||||
className="p-1 text-text-muted/40 opacity-0 group-hover:opacity-100 transition-all shrink-0 cursor-help"
|
||||
title={t("keyOnlyAvailableAtCreation")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied === key.id ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-[14px]">lock</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
|
||||
@@ -58,8 +58,10 @@ export default function ClaudeToolCard({
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
useEffect(() => {
|
||||
// (#523) Store the key *id* (not the masked string) so the backend can
|
||||
// resolve the real secret from DB before writing to settings.json.
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
setSelectedApiKey(apiKeys[0].id);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
@@ -95,10 +97,11 @@ export default function ClaudeToolCard({
|
||||
}
|
||||
}
|
||||
});
|
||||
// Only set selectedApiKey if it exists in apiKeys list
|
||||
// Restore selected key from file: match token stored in file against known keys
|
||||
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
|
||||
if (tokenFromFile && apiKeys?.some((k) => k.key === tokenFromFile)) {
|
||||
setSelectedApiKey(tokenFromFile);
|
||||
if (tokenFromFile) {
|
||||
const matchedKey = apiKeys?.find((k) => k.key === tokenFromFile);
|
||||
if (matchedKey) setSelectedApiKey(matchedKey.id);
|
||||
}
|
||||
}
|
||||
}, [claudeStatus, apiKeys, tool.defaultModels, onModelMappingChange]);
|
||||
@@ -132,24 +135,27 @@ export default function ClaudeToolCard({
|
||||
try {
|
||||
const env: any = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
|
||||
|
||||
// Get key from dropdown, fallback to first key or sk_omniroute for localhost
|
||||
const keyToUse =
|
||||
selectedApiKey?.trim() ||
|
||||
(apiKeys?.length > 0 ? apiKeys[0].key : null) ||
|
||||
(!cloudEnabled ? "sk_omniroute" : null);
|
||||
// (#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.
|
||||
const selectedKeyId = selectedApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
const skOmnirouteFallback = !cloudEnabled ? "sk_omniroute" : null;
|
||||
|
||||
if (keyToUse) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = keyToUse;
|
||||
if (!selectedKeyId && skOmnirouteFallback) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = skOmnirouteFallback;
|
||||
}
|
||||
|
||||
tool.defaultModels.forEach((model) => {
|
||||
const targetModel = modelMappings[model.alias];
|
||||
if (targetModel && model.envKey) env[model.envKey] = targetModel;
|
||||
});
|
||||
|
||||
const postBody: Record<string, unknown> = { env };
|
||||
if (selectedKeyId) postBody.keyId = selectedKeyId;
|
||||
|
||||
const res = await fetch("/api/cli-tools/claude-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env }),
|
||||
body: JSON.stringify(postBody),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
@@ -412,7 +418,7 @@ export default function ClaudeToolCard({
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
{apiKeys.map((key) => (
|
||||
<option key={key.id} value={key.key}>
|
||||
<option key={key.id} value={key.id}>
|
||||
{key.key}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliSettingsEnvSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
|
||||
// Get claude settings path based on OS
|
||||
const getClaudeSettingsPath = () => getCliPrimaryConfigPath("claude");
|
||||
@@ -100,6 +101,22 @@ export async function POST(request: Request) {
|
||||
}
|
||||
const { env } = validation.data;
|
||||
|
||||
// (#523/#526) If a keyId was provided, resolve the real API key from DB.
|
||||
// The /api/keys list endpoint returns masked key strings — sending those to
|
||||
// disk would save an unusable half-hidden token. Resolving by ID guarantees
|
||||
// we always write the full key value to the config file.
|
||||
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
|
||||
if (keyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(keyId);
|
||||
if (keyRecord?.key) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = keyRecord.key as string;
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: fall back to whatever value was in env (e.g. sk_omniroute)
|
||||
}
|
||||
}
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const claudeDir = path.dirname(settingsPath);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliModelConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
|
||||
const CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data");
|
||||
const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json");
|
||||
@@ -125,7 +126,18 @@ export async function POST(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, apiKey, model } = validation.data;
|
||||
let { baseUrl, apiKey, model } = validation.data;
|
||||
|
||||
// (#526) Resolve real key from DB if keyId was provided
|
||||
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
|
||||
if (keyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(keyId);
|
||||
if (keyRecord?.key) apiKey = keyRecord.key as string;
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(CLINE_DATA_DIR, { recursive: true });
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliModelConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
|
||||
const getOpenClawSettingsPath = () => getCliPrimaryConfigPath("openclaw");
|
||||
const getOpenClawDir = () => path.dirname(getOpenClawSettingsPath());
|
||||
@@ -101,7 +102,18 @@ export async function POST(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, apiKey, model } = validation.data;
|
||||
let { baseUrl, apiKey, model } = validation.data;
|
||||
|
||||
// (#526) Resolve real key from DB if keyId was provided
|
||||
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
|
||||
if (keyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(keyId);
|
||||
if (keyRecord?.key) apiKey = keyRecord.key as string;
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
const openclawDir = getOpenClawDir();
|
||||
const settingsPath = getOpenClawSettingsPath();
|
||||
|
||||
@@ -160,6 +160,7 @@ export async function POST(request) {
|
||||
// Resolve provider config — dynamic first (local override), then hardcoded
|
||||
let providerConfig: EmbeddingProvider | null =
|
||||
dynamicProviders.find((dp) => dp.id === provider) || getEmbeddingProvider(provider) || null;
|
||||
let credentialsProviderId = provider;
|
||||
|
||||
// #496: Fallback — resolve from ALL provider_nodes (not just localhost)
|
||||
// This enables custom embedding models (e.g. google/gemini-embedding-001) whose
|
||||
@@ -180,6 +181,7 @@ export async function POST(request) {
|
||||
authHeader: "bearer",
|
||||
models: [],
|
||||
};
|
||||
credentialsProviderId = matchingNode.id || provider;
|
||||
log.info(
|
||||
"EMBED",
|
||||
`Resolved custom embedding provider: ${provider} → ${providerConfig.baseUrl}`
|
||||
@@ -200,7 +202,7 @@ export async function POST(request) {
|
||||
// Get credentials — skip for local providers (authType: "none")
|
||||
let credentials = null;
|
||||
if (providerConfig && providerConfig.authType !== "none") {
|
||||
credentials = await getProviderCredentials(provider);
|
||||
credentials = await getProviderCredentials(credentialsProviderId);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
|
||||
@@ -69,6 +69,11 @@ export default function LoginPage() {
|
||||
router.refresh();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
// (#521) If no password is set, redirect to onboarding instead of showing an error
|
||||
if (data.needsSetup) {
|
||||
router.push("/dashboard/onboarding");
|
||||
return;
|
||||
}
|
||||
setError(data.error || t("invalidPassword"));
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
"failedUpdatePermissionsRetry": "Failed to update permissions. Please try again.",
|
||||
"unknownProvider": "unknown",
|
||||
"copyMaskedKey": "Copy masked key",
|
||||
"keyOnlyAvailableAtCreation": "Full key available only at creation time — copy it when you first create the key",
|
||||
"modelsCount": "{count, plural, one {# model} other {# models}}",
|
||||
"lastUsedOn": "Last: {date}",
|
||||
"editPermissions": "Edit permissions",
|
||||
|
||||
@@ -610,7 +610,12 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
}
|
||||
|
||||
const modelId = entry.models?.[0]?.id || null;
|
||||
const baseUrl = resolveBaseUrl(entry, providerSpecificData);
|
||||
// (#532) Use testKeyBaseUrl if defined — some providers validate keys on a different endpoint
|
||||
// than where requests are sent (e.g. opencode-go validates on zen/v1, not zen/go/v1)
|
||||
const validationEntry = entry.testKeyBaseUrl
|
||||
? { ...entry, baseUrl: entry.testKeyBaseUrl }
|
||||
: entry;
|
||||
const baseUrl = resolveBaseUrl(validationEntry, providerSpecificData);
|
||||
|
||||
try {
|
||||
if (OPENAI_LIKE_FORMATS.has(entry.format)) {
|
||||
|
||||
@@ -496,6 +496,22 @@ export const APIKEY_PROVIDERS = {
|
||||
website: "https://tavily.com",
|
||||
authHint: "API key from app.tavily.com (format: tvly-...)",
|
||||
},
|
||||
"opencode-zen": {
|
||||
id: "opencode-zen",
|
||||
alias: "opencode-zen",
|
||||
name: "OpenCode Zen",
|
||||
icon: "opencode",
|
||||
color: "#6366f1",
|
||||
website: "https://opencode.ai/zen",
|
||||
},
|
||||
"opencode-go": {
|
||||
id: "opencode-go",
|
||||
alias: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
icon: "opencode",
|
||||
color: "#6366f1",
|
||||
website: "https://opencode.ai/zen/go",
|
||||
},
|
||||
alibaba: {
|
||||
id: "alibaba",
|
||||
alias: "ali",
|
||||
|
||||
@@ -105,6 +105,24 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
|
||||
const isWindows = () => process.platform === "win32";
|
||||
|
||||
/**
|
||||
* (#510) Normalize MSYS2/Git-Bash style paths to Windows-native paths.
|
||||
* On Windows with Git Bash, 'where claude' may return '/c/Program Files/...'
|
||||
* instead of 'C:\\Program Files\\...'. Convert these so the path is usable
|
||||
* by Node's fs and child_process modules.
|
||||
*/
|
||||
const normalizeMsys2Path = (p: string): string => {
|
||||
if (!p || !isWindows()) return p;
|
||||
// Match /letter/rest-of-path — MSYS2 POSIX-style drive mount
|
||||
const msys2Match = p.match(/^\/([a-zA-Z])\/(.+)$/);
|
||||
if (msys2Match) {
|
||||
const drive = msys2Match[1].toUpperCase();
|
||||
const rest = msys2Match[2].replace(/\//g, "\\");
|
||||
return `${drive}:\\${rest}`;
|
||||
}
|
||||
return p;
|
||||
};
|
||||
|
||||
const parseBoolean = (value: unknown, defaultValue = true) => {
|
||||
if (value == null || value === "") return defaultValue;
|
||||
return !FALSE_VALUES.has(String(value).trim().toLowerCase());
|
||||
@@ -256,7 +274,7 @@ const locateCommand = async (command: string, env: Record<string, string | undef
|
||||
const first =
|
||||
located.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.map((line) => normalizeMsys2Path(line.trim()))
|
||||
.find(Boolean) || null;
|
||||
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
|
||||
}
|
||||
@@ -271,7 +289,7 @@ const locateCommand = async (command: string, env: Record<string, string | undef
|
||||
const first =
|
||||
located.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.map((line) => normalizeMsys2Path(line.trim()))
|
||||
.find(Boolean) || null;
|
||||
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
|
||||
};
|
||||
|
||||
@@ -106,3 +106,60 @@ test("embeddings route clears stale provider error metadata on success", async (
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("embeddings route uses provider node id for compatible provider credentials", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const providerNode = await providersDb.createProviderNode({
|
||||
id: "openai-compatible-responses-google-embeddings",
|
||||
type: "openai-compatible",
|
||||
name: "Gemini Embeddings",
|
||||
prefix: "google",
|
||||
apiType: "responses",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
});
|
||||
|
||||
const created = await providersDb.createProviderConnection({
|
||||
provider: providerNode.id,
|
||||
authType: "apikey",
|
||||
email: null,
|
||||
name: "google-compatible-key",
|
||||
apiKey: "google-compatible-test-key",
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
lastErrorType: "token_refresh_failed",
|
||||
lastErrorSource: "oauth",
|
||||
errorCode: "refresh_failed",
|
||||
rateLimitedUntil: null,
|
||||
backoffLevel: 2,
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
assert.equal(url, "https://generativelanguage.googleapis.com/v1beta/openai/embeddings");
|
||||
assert.equal(init?.headers?.Authorization, "Bearer google-compatible-test-key");
|
||||
return Response.json({
|
||||
data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2] }],
|
||||
usage: { prompt_tokens: 3, total_tokens: 3 },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const request = new Request("http://localhost/v1/embeddings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "google/gemini-embedding-001", input: "hello" }),
|
||||
});
|
||||
|
||||
const response = await embeddingsRoute.POST(request);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const updated = await readConnection(created.id);
|
||||
assert.equal(updated.testStatus, "active");
|
||||
assert.equal(updated.errorCode, undefined);
|
||||
assert.equal(updated.lastErrorType, undefined);
|
||||
assert.equal(updated.lastErrorSource, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts");
|
||||
const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts");
|
||||
|
||||
function createMockResponse() {
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function createInput(model, stream = true, credentials = { apiKey: "test-key" }) {
|
||||
return {
|
||||
model,
|
||||
stream,
|
||||
credentials,
|
||||
body: {
|
||||
model,
|
||||
stream,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerModel(provider, model) {
|
||||
PROVIDER_MODELS[provider] = [...(PROVIDER_MODELS[provider] || []), model];
|
||||
}
|
||||
|
||||
describe("OpencodeExecutor", () => {
|
||||
let zenExecutor;
|
||||
let goExecutor;
|
||||
let fetchCalls;
|
||||
let originalFetch;
|
||||
let originalZenModels;
|
||||
let originalGoModels;
|
||||
|
||||
beforeEach(() => {
|
||||
zenExecutor = new OpencodeExecutor("opencode-zen");
|
||||
goExecutor = new OpencodeExecutor("opencode-go");
|
||||
fetchCalls = [];
|
||||
originalFetch = globalThis.fetch;
|
||||
originalZenModels = [...(PROVIDER_MODELS["opencode-zen"] || [])];
|
||||
originalGoModels = [...(PROVIDER_MODELS["opencode-go"] || [])];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
fetchCalls.push({ url, options });
|
||||
return createMockResponse();
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
PROVIDER_MODELS["opencode-zen"] = originalZenModels;
|
||||
PROVIDER_MODELS["opencode-go"] = originalGoModels;
|
||||
});
|
||||
|
||||
describe("execute", () => {
|
||||
it("routes opencode zen default models to chat completions", async () => {
|
||||
const minimaxResult = await zenExecutor.execute(createInput("minimax-m2.5-free"));
|
||||
assert.equal(minimaxResult.url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
|
||||
const pickleResult = await zenExecutor.execute(createInput("big-pickle"));
|
||||
assert.equal(pickleResult.url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
assert.equal(fetchCalls[1].url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
|
||||
const nanoResult = await zenExecutor.execute(createInput("gpt-5-nano"));
|
||||
assert.equal(nanoResult.url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
assert.equal(fetchCalls[2].url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
});
|
||||
|
||||
it("routes claude target format models to messages endpoint", async () => {
|
||||
const m27Result = await goExecutor.execute(
|
||||
createInput("minimax-m2.7", true, { apiKey: "claude-key" })
|
||||
);
|
||||
assert.equal(m27Result.url, "https://opencode.ai/zen/go/v1/messages");
|
||||
assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/go/v1/messages");
|
||||
assert.equal(m27Result.headers["anthropic-version"], "2023-06-01");
|
||||
|
||||
const m25Result = await goExecutor.execute(
|
||||
createInput("minimax-m2.5", true, { apiKey: "claude-key" })
|
||||
);
|
||||
assert.equal(m25Result.url, "https://opencode.ai/zen/go/v1/messages");
|
||||
assert.equal(fetchCalls[1].url, "https://opencode.ai/zen/go/v1/messages");
|
||||
assert.equal(m25Result.headers["anthropic-version"], "2023-06-01");
|
||||
});
|
||||
|
||||
it("routes openai responses target format models to responses endpoint", async () => {
|
||||
registerModel("opencode-zen", {
|
||||
id: "gpt-5-responses",
|
||||
name: "GPT 5 Responses",
|
||||
targetFormat: "openai-responses",
|
||||
});
|
||||
|
||||
const result = await zenExecutor.execute(createInput("gpt-5-responses"));
|
||||
|
||||
assert.equal(result.url, "https://opencode.ai/zen/v1/responses");
|
||||
assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/responses");
|
||||
});
|
||||
|
||||
it("routes gemini streaming requests to streamGenerateContent", async () => {
|
||||
registerModel("opencode-zen", {
|
||||
id: "gemini-2.5-pro",
|
||||
name: "Gemini 2.5 Pro",
|
||||
targetFormat: "gemini",
|
||||
});
|
||||
|
||||
const result = await zenExecutor.execute(createInput("gemini-2.5-pro"));
|
||||
|
||||
assert.equal(
|
||||
result.url,
|
||||
"https://opencode.ai/zen/v1/models/gemini-2.5-pro:streamGenerateContent?alt=sse"
|
||||
);
|
||||
assert.equal(
|
||||
fetchCalls[0].url,
|
||||
"https://opencode.ai/zen/v1/models/gemini-2.5-pro:streamGenerateContent?alt=sse"
|
||||
);
|
||||
});
|
||||
|
||||
it("routes gemini non streaming requests to generateContent", async () => {
|
||||
registerModel("opencode-zen", {
|
||||
id: "gemini-2.5-pro",
|
||||
name: "Gemini 2.5 Pro",
|
||||
targetFormat: "gemini",
|
||||
});
|
||||
|
||||
const result = await zenExecutor.execute(createInput("gemini-2.5-pro", false));
|
||||
|
||||
assert.equal(result.url, "https://opencode.ai/zen/v1/models/gemini-2.5-pro:generateContent");
|
||||
assert.equal(
|
||||
fetchCalls[0].url,
|
||||
"https://opencode.ai/zen/v1/models/gemini-2.5-pro:generateContent"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to chat completions for unknown models", async () => {
|
||||
const result = await zenExecutor.execute(createInput("unknown-model"));
|
||||
|
||||
assert.equal(result.url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
});
|
||||
|
||||
it("builds default headers for standard models", async () => {
|
||||
const result = await zenExecutor.execute(createInput("gpt-5-nano"));
|
||||
|
||||
assert.deepEqual(result.headers, {
|
||||
Authorization: "Bearer test-key",
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
});
|
||||
assert.deepEqual(fetchCalls[0].options.headers, result.headers);
|
||||
});
|
||||
|
||||
it("adds anthropic version for claude target format", async () => {
|
||||
const result = await goExecutor.execute(
|
||||
createInput("minimax-m2.7", true, { apiKey: "claude-key" })
|
||||
);
|
||||
|
||||
assert.deepEqual(result.headers, {
|
||||
Authorization: "Bearer claude-key",
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
Accept: "text/event-stream",
|
||||
});
|
||||
assert.deepEqual(fetchCalls[0].options.headers, result.headers);
|
||||
});
|
||||
|
||||
it("omits accept header when stream is false", async () => {
|
||||
const result = await zenExecutor.execute(createInput("big-pickle", false));
|
||||
|
||||
assert.deepEqual(result.headers, {
|
||||
Authorization: "Bearer test-key",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
assert.deepEqual(fetchCalls[0].options.headers, result.headers);
|
||||
});
|
||||
|
||||
it("omits authorization when credentials are missing", async () => {
|
||||
const result = await zenExecutor.execute(createInput("minimax-m2.5-free", true, null));
|
||||
|
||||
assert.deepEqual(result.headers, {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
});
|
||||
assert.deepEqual(fetchCalls[0].options.headers, result.headers);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user