mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(opencode-plugin): remove duplicated blocks + wire missing schema fields (#3435 merge corruption)
PR #3435's branch shipped a corrupted index.ts that never built — the npm publish-opencode-plugin job failed on DTS errors. Root causes: - Duplicate apiFormat block (ensureV1Suffix/DEFAULT_ANTHROPIC_PREFIXES/ resolveApiBlock) — kept the canonical #3420 copy (anthropic url WITHOUT /v1), removed the duplicate that wrongly appended /v1 to the Anthropic SDK base. - Duplicate debug-logging block (DebugLogEntry + debugLog* + createDebugLoggingFetch) with mid-file imports — kept the canonical copy using top-of-file imports. - Local normaliseFreeLabel def superseded by the naming.ts extraction — removed it, routed the lone caller to the imported _normaliseFreeLabel. - sdkBaseURL → resolvedBaseURL (undefined identifier in the auth loader). - featuresSchema missing startupDebug + logLevel (referenced but never declared). - shortProviderLabel dropped the prefix on long displayName + no alias; now keeps the long label, matching the test intent. Plugin builds (DTS clean) and all 254 tests pass.
This commit is contained in:
@@ -174,6 +174,8 @@ const featuresSchema = z
|
|||||||
diskCache: z.boolean().optional(),
|
diskCache: z.boolean().optional(),
|
||||||
providerTag: z.boolean().optional(),
|
providerTag: z.boolean().optional(),
|
||||||
debugLog: z.boolean().optional(),
|
debugLog: z.boolean().optional(),
|
||||||
|
startupDebug: z.boolean().optional(),
|
||||||
|
logLevel: z.enum(["error", "warn", "info", "debug"]).optional(),
|
||||||
apiFormat: apiFormatSchema,
|
apiFormat: apiFormatSchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -224,59 +226,6 @@ function trimTrailingSlashes(value: string): string {
|
|||||||
return i === value.length ? value : value.slice(0, i);
|
return i === value.length ? value : value.slice(0, i);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs
|
|
||||||
* `/v1/chat/completions` instead of `/chat/completions`.
|
|
||||||
* No-op when the URL already ends with `/v1` or `/v1/`.
|
|
||||||
*/
|
|
||||||
export function ensureV1Suffix(url: string): string {
|
|
||||||
const trimmed = trimTrailingSlashes(url);
|
|
||||||
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default provider prefixes that should use the Anthropic-native SDK.
|
|
||||||
* Covers all OmniRoute aliases that route to Anthropic upstream.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_ANTHROPIC_PREFIXES = [
|
|
||||||
"cc",
|
|
||||||
"claude",
|
|
||||||
"anthropic",
|
|
||||||
"kiro",
|
|
||||||
"kr",
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the `api` block for a ModelV2 entry given a model id and the
|
|
||||||
* active `apiFormat` feature config.
|
|
||||||
*
|
|
||||||
* - Models whose prefix (before the first `/`) is in `anthropicPrefixes` →
|
|
||||||
* `{ id: "anthropic", url: baseURL/v1, npm: "@ai-sdk/anthropic" }`
|
|
||||||
*
|
|
||||||
* - All others →
|
|
||||||
* `{ id: "openai-compatible", url: baseURL/v1, npm: "@ai-sdk/openai-compatible" }`
|
|
||||||
*/
|
|
||||||
export function resolveApiBlock(
|
|
||||||
modelId: string,
|
|
||||||
baseURL: string,
|
|
||||||
apiFormat?: { anthropicPrefixes?: string[] },
|
|
||||||
): { id: string; url: string; npm: string } {
|
|
||||||
const prefixes = apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES;
|
|
||||||
const slash = modelId.indexOf("/");
|
|
||||||
const prefix = slash === -1 ? modelId : modelId.slice(0, slash);
|
|
||||||
const isAnthropic = prefixes.includes(prefix);
|
|
||||||
return isAnthropic
|
|
||||||
? {
|
|
||||||
id: "anthropic",
|
|
||||||
url: ensureV1Suffix(baseURL),
|
|
||||||
npm: "@ai-sdk/anthropic",
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
id: "openai-compatible",
|
|
||||||
url: ensureV1Suffix(baseURL),
|
|
||||||
npm: "@ai-sdk/openai-compatible",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function trimTrailingDashes(value: string): string {
|
function trimTrailingDashes(value: string): string {
|
||||||
let i = value.length;
|
let i = value.length;
|
||||||
while (i > 0 && value.charCodeAt(i - 1) === 0x2d /* "-" */) i--;
|
while (i > 0 && value.charCodeAt(i - 1) === 0x2d /* "-" */) i--;
|
||||||
@@ -429,31 +378,6 @@ export function resolveApiBlock(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Free-label normalisation
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalise a model display name so free-tier models always carry a
|
|
||||||
* consistent `[Free] ` prefix instead of a trailing `(Free)` suffix or
|
|
||||||
* an ad-hoc `free` word anywhere in the name.
|
|
||||||
*
|
|
||||||
* "GPT-4.1 (Free)" → "[Free] GPT-4.1"
|
|
||||||
* "DeepSeek V4 Flash Free" → "[Free] DeepSeek V4 Flash"
|
|
||||||
* "Claude 4.7 Opus" → "Claude 4.7 Opus" (no change)
|
|
||||||
*
|
|
||||||
* Non-matching names pass through untouched.
|
|
||||||
*/
|
|
||||||
export function normaliseFreeLabel(name: string): string {
|
|
||||||
// Remove trailing " (Free)" or " free" (case-insensitive, hyphen-tolerant)
|
|
||||||
const cleaned = name
|
|
||||||
.replace(/\s*\(free\)\s*$/i, "")
|
|
||||||
.replace(/[\s-]+free\s*$/i, "")
|
|
||||||
.trim();
|
|
||||||
const wasFree = cleaned.length < name.trim().length;
|
|
||||||
if (!wasFree) return name;
|
|
||||||
return `[Free] ${cleaned}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the AuthHook portion of the plugin for a given options bag. Exported
|
* Build the AuthHook portion of the plugin for a given options bag. Exported
|
||||||
@@ -560,8 +484,8 @@ export function createOmniRouteAuthHook(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return composedFetch
|
return composedFetch
|
||||||
? { apiKey, baseURL: sdkBaseURL, fetch: composedFetch }
|
? { apiKey, baseURL: resolvedBaseURL, fetch: composedFetch }
|
||||||
: { apiKey, baseURL: sdkBaseURL };
|
: { apiKey, baseURL: resolvedBaseURL };
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
@@ -2014,7 +1938,7 @@ export function applyEnrichment(
|
|||||||
): ModelV2 {
|
): ModelV2 {
|
||||||
if (!enrichment) return model;
|
if (!enrichment) return model;
|
||||||
if (enrichment.name && enrichment.name.trim().length > 0) {
|
if (enrichment.name && enrichment.name.trim().length > 0) {
|
||||||
model.name = normaliseFreeLabel(enrichment.name);
|
model.name = _normaliseFreeLabel(enrichment.name);
|
||||||
}
|
}
|
||||||
if (enrichment.pricing) {
|
if (enrichment.pricing) {
|
||||||
if (typeof enrichment.pricing.input === "number") {
|
if (typeof enrichment.pricing.input === "number") {
|
||||||
@@ -3029,276 +2953,6 @@ export function createOmniRouteProviderHook(
|
|||||||
* @see https://opencode.ai/docs/plugins for the AuthLoaderResult.fetch contract
|
* @see https://opencode.ai/docs/plugins for the AuthLoaderResult.fetch contract
|
||||||
* (the returned function is invoked by the AI-SDK in lieu of global fetch).
|
* (the returned function is invoked by the AI-SDK in lieu of global fetch).
|
||||||
*/
|
*/
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// DEBUG LOGGING — request/response capture (features.debugLog)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { homedir } from "os";
|
|
||||||
import {
|
|
||||||
appendFileSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "fs";
|
|
||||||
import { join } from "path";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
|
|
||||||
export interface DebugLogEntry {
|
|
||||||
reqId: string;
|
|
||||||
providerId: string;
|
|
||||||
ts: number;
|
|
||||||
url: string;
|
|
||||||
method: string;
|
|
||||||
reqHeaders: Record<string, string>;
|
|
||||||
reqBody: unknown;
|
|
||||||
resStatus: number | null;
|
|
||||||
resHeaders: Record<string, string>;
|
|
||||||
resBody: unknown;
|
|
||||||
durationMs: number | null;
|
|
||||||
error?: string;
|
|
||||||
/** Parsed from SSE trailing comments (real values when headers show 0) */
|
|
||||||
sseTokensIn?: number;
|
|
||||||
sseTokensOut?: number;
|
|
||||||
sseCost?: number;
|
|
||||||
sseModel?: string;
|
|
||||||
sseProvider?: string;
|
|
||||||
/** Parsed assistant reply text from SSE stream */
|
|
||||||
replyText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse OmniRoute SSE trailing comments + assistant content from a stream body string. */
|
|
||||||
export function parseSseDebugMeta(body: string): {
|
|
||||||
tokensIn?: number;
|
|
||||||
tokensOut?: number;
|
|
||||||
cost?: number;
|
|
||||||
model?: string;
|
|
||||||
provider?: string;
|
|
||||||
replyText?: string;
|
|
||||||
} {
|
|
||||||
if (typeof body !== "string") return {};
|
|
||||||
const result: ReturnType<typeof parseSseDebugMeta> = {};
|
|
||||||
let reply = "";
|
|
||||||
for (const line of body.split("\n")) {
|
|
||||||
const t = line.trim();
|
|
||||||
// OmniRoute SSE comment metadata: `: x-omniroute-tokens-in=333`
|
|
||||||
if (t.startsWith(":")) {
|
|
||||||
const kv = t.slice(1).trim();
|
|
||||||
const eq = kv.indexOf("=");
|
|
||||||
if (eq === -1) continue;
|
|
||||||
const k = kv.slice(0, eq).trim();
|
|
||||||
const v = kv.slice(eq + 1).trim();
|
|
||||||
if (k === "x-omniroute-tokens-in") result.tokensIn = Number(v);
|
|
||||||
else if (k === "x-omniroute-tokens-out") result.tokensOut = Number(v);
|
|
||||||
else if (k === "x-omniroute-response-cost") result.cost = Number(v);
|
|
||||||
else if (k === "x-omniroute-model") result.model = v;
|
|
||||||
else if (k === "x-omniroute-provider") result.provider = v;
|
|
||||||
}
|
|
||||||
// Parse assistant content from data chunks
|
|
||||||
if (t.startsWith("data:") && !t.includes("[DONE]")) {
|
|
||||||
try {
|
|
||||||
const chunk = JSON.parse(t.slice(5)) as {
|
|
||||||
choices?: Array<{ delta?: { content?: string } }>;
|
|
||||||
};
|
|
||||||
reply += chunk.choices?.[0]?.delta?.content ?? "";
|
|
||||||
} catch {
|
|
||||||
/**/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (reply) result.replyText = reply;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function debugLogDir(): string {
|
|
||||||
const dir = join(homedir(), ".local", "share", "opencode", "plugins");
|
|
||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
function debugLogPath(providerId: string): string {
|
|
||||||
return join(debugLogDir(), `omniroute-debug-${providerId}.jsonl`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function debugStatePath(providerId: string): string {
|
|
||||||
return join(debugLogDir(), `omniroute-debug-${providerId}.state.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function debugLogEnabled(providerId: string): boolean {
|
|
||||||
try {
|
|
||||||
const p = debugStatePath(providerId);
|
|
||||||
if (!existsSync(p)) return false;
|
|
||||||
const s = JSON.parse(readFileSync(p, "utf8")) as { enabled?: boolean };
|
|
||||||
return s.enabled === true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function debugLogSetEnabled(providerId: string, enabled: boolean): void {
|
|
||||||
writeFileSync(
|
|
||||||
debugStatePath(providerId),
|
|
||||||
JSON.stringify({ enabled }),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function debugLogAppend(entry: DebugLogEntry): void {
|
|
||||||
appendFileSync(
|
|
||||||
debugLogPath(entry.providerId),
|
|
||||||
JSON.stringify(entry) + "\n",
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function debugLogRead(providerId: string, limit = 20): DebugLogEntry[] {
|
|
||||||
const p = debugLogPath(providerId);
|
|
||||||
if (!existsSync(p)) return [];
|
|
||||||
const lines = readFileSync(p, "utf8").trim().split("\n").filter(Boolean);
|
|
||||||
return lines
|
|
||||||
.slice(-limit)
|
|
||||||
.map((l) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(l) as DebugLogEntry;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((e): e is DebugLogEntry => e !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function debugLogGetById(
|
|
||||||
providerId: string,
|
|
||||||
reqId: string,
|
|
||||||
): DebugLogEntry | null {
|
|
||||||
const p = debugLogPath(providerId);
|
|
||||||
if (!existsSync(p)) return null;
|
|
||||||
const lines = readFileSync(p, "utf8").trim().split("\n").filter(Boolean);
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
try {
|
|
||||||
const e = JSON.parse(lines[i]) as DebugLogEntry;
|
|
||||||
if (e.reqId === reqId) return e;
|
|
||||||
} catch {
|
|
||||||
/* skip */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function debugLogClear(providerId: string): void {
|
|
||||||
const p = debugLogPath(providerId);
|
|
||||||
if (existsSync(p)) writeFileSync(p, "", "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps a fetch function to capture request/response pairs into the debug
|
|
||||||
* JSONL log. Only active when `debugLogEnabled(providerId)` returns true at
|
|
||||||
* call time — the flag is read on every request so enable/disable takes
|
|
||||||
* effect immediately without restarting OpenCode.
|
|
||||||
*/
|
|
||||||
export function createDebugLoggingFetch(
|
|
||||||
inner: typeof fetch,
|
|
||||||
providerId: string,
|
|
||||||
featureDefault: boolean,
|
|
||||||
): typeof fetch {
|
|
||||||
return async (input, init = {}) => {
|
|
||||||
const active = featureDefault || debugLogEnabled(providerId);
|
|
||||||
if (!active) return inner(input, init);
|
|
||||||
|
|
||||||
const reqId = randomUUID();
|
|
||||||
const url =
|
|
||||||
typeof input === "string"
|
|
||||||
? input
|
|
||||||
: input instanceof URL
|
|
||||||
? input.toString()
|
|
||||||
: (input as Request).url;
|
|
||||||
const method =
|
|
||||||
init.method ?? (input instanceof Request ? input.method : "GET");
|
|
||||||
|
|
||||||
const reqHeaders: Record<string, string> = {};
|
|
||||||
const headerSrc = init.headers
|
|
||||||
? new Headers(init.headers)
|
|
||||||
: input instanceof Request
|
|
||||||
? input.headers
|
|
||||||
: new Headers();
|
|
||||||
headerSrc.forEach((v, k) => {
|
|
||||||
reqHeaders[k] = k.toLowerCase() === "authorization" ? "[redacted]" : v;
|
|
||||||
});
|
|
||||||
|
|
||||||
let reqBody: unknown = undefined;
|
|
||||||
try {
|
|
||||||
if (init.body) {
|
|
||||||
reqBody =
|
|
||||||
typeof init.body === "string" ? JSON.parse(init.body) : "[binary]";
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
reqBody = "[unparseable]";
|
|
||||||
}
|
|
||||||
|
|
||||||
const t0 = Date.now();
|
|
||||||
try {
|
|
||||||
const res = await inner(input, init);
|
|
||||||
const durationMs = Date.now() - t0;
|
|
||||||
|
|
||||||
const resHeaders: Record<string, string> = {};
|
|
||||||
res.headers.forEach((v, k) => {
|
|
||||||
resHeaders[k] = v;
|
|
||||||
});
|
|
||||||
|
|
||||||
let resBody: unknown = undefined;
|
|
||||||
const clone = res.clone();
|
|
||||||
try {
|
|
||||||
const ct = res.headers.get("content-type") ?? "";
|
|
||||||
resBody = ct.includes("application/json")
|
|
||||||
? await clone.json()
|
|
||||||
: await clone.text();
|
|
||||||
} catch {
|
|
||||||
resBody = "[unreadable]";
|
|
||||||
}
|
|
||||||
|
|
||||||
const sseMeta =
|
|
||||||
typeof resBody === "string" ? parseSseDebugMeta(resBody) : {};
|
|
||||||
debugLogAppend({
|
|
||||||
reqId,
|
|
||||||
providerId,
|
|
||||||
ts: t0,
|
|
||||||
url,
|
|
||||||
method,
|
|
||||||
reqHeaders,
|
|
||||||
reqBody,
|
|
||||||
resStatus: res.status,
|
|
||||||
resHeaders,
|
|
||||||
resBody,
|
|
||||||
durationMs,
|
|
||||||
sseTokensIn: sseMeta.tokensIn,
|
|
||||||
sseTokensOut: sseMeta.tokensOut,
|
|
||||||
sseCost: sseMeta.cost,
|
|
||||||
sseModel: sseMeta.model,
|
|
||||||
sseProvider: sseMeta.provider,
|
|
||||||
replyText: sseMeta.replyText,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res;
|
|
||||||
} catch (err) {
|
|
||||||
debugLogAppend({
|
|
||||||
reqId,
|
|
||||||
providerId,
|
|
||||||
ts: t0,
|
|
||||||
url,
|
|
||||||
method,
|
|
||||||
reqHeaders,
|
|
||||||
reqBody,
|
|
||||||
resStatus: null,
|
|
||||||
resHeaders: {},
|
|
||||||
resBody: null,
|
|
||||||
durationMs: Date.now() - t0,
|
|
||||||
error: String(err),
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOmniRouteFetchInterceptor(config: {
|
export function createOmniRouteFetchInterceptor(config: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseURL: string;
|
baseURL: string;
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ export function shortProviderLabel(
|
|||||||
? alias.toUpperCase()
|
? alias.toUpperCase()
|
||||||
: titleCaseAlias(alias);
|
: titleCaseAlias(alias);
|
||||||
}
|
}
|
||||||
return undefined;
|
// Long displayName with no alias to fall back on: keep the long label
|
||||||
|
// rather than dropping the provider prefix entirely.
|
||||||
|
return raw.length > 0 ? raw : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Free Label ────────────────────────────────────────────────────────────
|
// ── Free Label ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user