mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-30 20:05:40 +03:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a15fda0c08 | ||
|
|
e5988764ce | ||
|
|
9c9d9b5a8d | ||
|
|
44dc564d85 | ||
|
|
83e367afab | ||
|
|
8b7e7c2669 | ||
|
|
53474021b7 | ||
|
|
da1ed1b5b2 | ||
|
|
e08d661600 | ||
|
|
1aa1bc7a26 | ||
|
|
47634e942e | ||
|
|
15466cbf1a | ||
|
|
2a749db427 | ||
|
|
ecccce86e4 |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -4,6 +4,24 @@
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.9.4
|
||||
version: 2.9.5
|
||||
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;
|
||||
|
||||
@@ -495,6 +495,39 @@ 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",
|
||||
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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
61
open-sse/executors/opencode.ts
Normal file
61
open-sse/executors/opencode.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.9.4",
|
||||
"version": "2.9.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.9.4",
|
||||
"version": "2.9.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.9.4",
|
||||
"version": "2.9.5",
|
||||
"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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
189
tests/unit/opencode-executor.test.mjs
Normal file
189
tests/unit/opencode-executor.test.mjs
Normal file
@@ -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