mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(opencode-plugin): per-prefix API format + debug logging + free-label normaliser (3 mrmm-fork backports) (#3420)
Integrated into release/v3.8.17
This commit is contained in:
committed by
GitHub
parent
da273d37e2
commit
1322411343
@@ -26,7 +26,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -49,6 +49,10 @@ import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin";
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import { z } from "zod";
|
||||
@@ -107,7 +111,38 @@ import { z } from "zod";
|
||||
* preferred over the chat/inference key.
|
||||
* - `fetchInterceptor` Inject Authorization: Bearer + Content-Type on
|
||||
* every outbound request to baseURL. Default true.
|
||||
* - `debugLog` Capture every outbound request + response to a
|
||||
* JSONL file at
|
||||
* `~/.local/share/opencode/plugins/omniroute-debug-{providerId}.jsonl`.
|
||||
* Each line: `{ reqId, ts, url, method, reqBody,
|
||||
* resStatus, resBody, durationMs }`.
|
||||
* Default false. Opt-in.
|
||||
* - `apiFormat` Per-provider-prefix API format routing. Model IDs
|
||||
* whose prefix (the part before `/`) matches an entry
|
||||
* in `anthropicPrefixes` are served via the Anthropic
|
||||
* SDK (`@ai-sdk/anthropic`, sends to `/v1/messages`
|
||||
* with native cache_control, tool_choice, etc.).
|
||||
* All other models fall back to `openai-compatible`.
|
||||
*
|
||||
* Default `anthropicPrefixes`:
|
||||
* ["cc", "claude", "anthropic", "kiro", "kr"]
|
||||
* (covers OmniRoute's canonical Anthropic aliases).
|
||||
*
|
||||
* Set `anthropicPrefixes: []` to disable and force
|
||||
* everything through OpenAI-compat.
|
||||
*
|
||||
* Example:
|
||||
* ```json
|
||||
* "apiFormat": { "anthropicPrefixes": ["cc","claude","anthropic","kiro"] }
|
||||
* ```
|
||||
*/
|
||||
const apiFormatSchema = z
|
||||
.object({
|
||||
anthropicPrefixes: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const featuresSchema = z
|
||||
.object({
|
||||
combos: z.boolean().optional(),
|
||||
@@ -120,6 +155,8 @@ const featuresSchema = z
|
||||
usableOnly: z.boolean().optional(),
|
||||
diskCache: z.boolean().optional(),
|
||||
providerTag: z.boolean().optional(),
|
||||
debugLog: z.boolean().optional(),
|
||||
apiFormat: apiFormatSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -238,6 +275,98 @@ function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions {
|
||||
return parseOmniRoutePluginOptions(opts);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Per-prefix API format routing (apiFormat feature)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default provider-prefix list that triggers the Anthropic SDK format.
|
||||
* Covers OmniRoute's canonical Anthropic aliases: `cc/`, `claude/`,
|
||||
* `anthropic/`, plus the user-configured `kiro/` and `kr/` upstream
|
||||
* connections that proxy Anthropic models.
|
||||
*/
|
||||
export const DEFAULT_ANTHROPIC_PREFIXES = [
|
||||
"cc",
|
||||
"claude",
|
||||
"anthropic",
|
||||
"kiro",
|
||||
"kr",
|
||||
];
|
||||
|
||||
/**
|
||||
* Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs
|
||||
* `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1`
|
||||
* (it appends `/v1/messages` automatically), so callers should branch on
|
||||
* format first.
|
||||
*/
|
||||
export function ensureV1Suffix(url: string): string {
|
||||
const trimmed = trimTrailingSlashes(url);
|
||||
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the API block (id + url + npm package) for a given model id.
|
||||
*
|
||||
* Decision matrix:
|
||||
* - If the model id's prefix (the substring before the first `/`) is in
|
||||
* `apiFormat.anthropicPrefixes` (or the default list), return the
|
||||
* Anthropic SDK block: `id: "anthropic"`, `url: baseURL` (no `/v1`),
|
||||
* `npm: "@ai-sdk/anthropic"`.
|
||||
* - Otherwise return the OpenAI-compat block: `id: "openai-compatible"`,
|
||||
* `url: baseURL + "/v1"`, `npm: "@ai-sdk/openai-compatible"`.
|
||||
*
|
||||
* Combos span multiple providers. Callers should pass each combo member's
|
||||
* id through this function and pick the LCD format (lowest common
|
||||
* denominator that every upstream actually understands).
|
||||
*/
|
||||
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: trimTrailingSlashes(baseURL),
|
||||
npm: "@ai-sdk/anthropic",
|
||||
}
|
||||
: {
|
||||
id: "openai-compatible",
|
||||
url: ensureV1Suffix(baseURL),
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
* standalone so the auth contract can be unit-tested without faking the full
|
||||
@@ -270,6 +399,7 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook
|
||||
// documented and schema-validated but silently ignored.
|
||||
const wantFetchInterceptor = (features ?? {}).fetchInterceptor !== false;
|
||||
const wantGeminiSanitization = (features ?? {}).geminiSanitization !== false;
|
||||
const wantDebugLog = (features ?? {}).debugLog === true;
|
||||
|
||||
const hook: AuthHook = {
|
||||
provider: providerId,
|
||||
@@ -330,6 +460,13 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook
|
||||
if (wantGeminiSanitization) {
|
||||
composedFetch = createGeminiSanitizingFetch(composedFetch ?? fetch);
|
||||
}
|
||||
if (wantDebugLog || debugLogEnabled(providerId)) {
|
||||
composedFetch = createDebugLoggingFetch(
|
||||
composedFetch ?? fetch,
|
||||
providerId,
|
||||
wantDebugLog
|
||||
);
|
||||
}
|
||||
return composedFetch
|
||||
? { apiKey, baseURL: resolvedBaseURL, fetch: composedFetch }
|
||||
: { apiKey, baseURL: resolvedBaseURL };
|
||||
@@ -561,7 +698,7 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
|
||||
|
||||
export function mapRawModelToModelV2(
|
||||
raw: OmniRouteRawModelEntry,
|
||||
ctx: { providerId: string; baseURL: string }
|
||||
ctx: { providerId: string; baseURL: string; apiFormat?: { anthropicPrefixes?: string[] } }
|
||||
): ModelV2 {
|
||||
const caps = raw.capabilities ?? {};
|
||||
const inMods = new Set(raw.input_modalities ?? ["text"]);
|
||||
@@ -612,11 +749,7 @@ export function mapRawModelToModelV2(
|
||||
headers: {},
|
||||
release_date: raw.release_date ?? "",
|
||||
providerID: ctx.providerId,
|
||||
api: {
|
||||
id: "openai-compatible",
|
||||
url: ctx.baseURL,
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
api: resolveApiBlock(raw.id, ctx.baseURL, ctx.apiFormat),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1410,7 +1543,7 @@ export function applyEnrichment(
|
||||
): ModelV2 {
|
||||
if (!enrichment) return model;
|
||||
if (enrichment.name && enrichment.name.trim().length > 0) {
|
||||
model.name = enrichment.name;
|
||||
model.name = normaliseFreeLabel(enrichment.name);
|
||||
}
|
||||
if (enrichment.pricing) {
|
||||
if (typeof enrichment.pricing.input === "number") {
|
||||
@@ -2134,6 +2267,7 @@ export function createOmniRouteProviderHook(
|
||||
const model = mapRawModelToModelV2(entry, {
|
||||
providerId: resolved.providerId,
|
||||
baseURL,
|
||||
apiFormat: resolved.features?.apiFormat,
|
||||
});
|
||||
const enrichEntry = lookupEnrichment(entry.id, rawEnrichment, canonicalToAlias);
|
||||
applyEnrichment(model, enrichEntry);
|
||||
@@ -3186,6 +3320,242 @@ export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (pro
|
||||
|
||||
/** No-op disk-cache pair — used by tests to avoid filesystem side effects. */
|
||||
export const noopDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Debug logging (features.debugLog)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One captured request/response pair written to the debug JSONL log.
|
||||
* Schema documented in the schema-aware `DebugLogEntry` interface below.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
function debugLogDir(): string {
|
||||
return join(
|
||||
process.env.OPENCODE_DATA_DIR ?? join(homedir(), ".local", "share", "opencode"),
|
||||
"plugins"
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
const dir = debugLogDir();
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(
|
||||
debugStatePath(providerId),
|
||||
JSON.stringify({ enabled, ts: Date.now() }, null, 2)
|
||||
);
|
||||
} catch (err) {
|
||||
// best-effort; never break the auth flow
|
||||
console.warn(`[omniroute-plugin] debugLogSetEnabled failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function debugLogAppend(entry: DebugLogEntry): void {
|
||||
try {
|
||||
const dir = debugLogDir();
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(debugLogPath(entry.providerId), JSON.stringify(entry) + "\n", "utf8");
|
||||
} catch (err) {
|
||||
console.warn(`[omniroute-plugin] debugLogAppend failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function debugLogRead(providerId: string, limit = 20): DebugLogEntry[] {
|
||||
try {
|
||||
const p = debugLogPath(providerId);
|
||||
if (!existsSync(p)) return [];
|
||||
const lines = readFileSync(p, "utf8").trim().split("\n").filter(Boolean);
|
||||
return lines
|
||||
.slice(-limit)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as DebugLogEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((e): e is DebugLogEntry => e !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function debugLogGetById(providerId: string, reqId: string): DebugLogEntry | null {
|
||||
try {
|
||||
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 malformed
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function debugLogClear(providerId: string): void {
|
||||
try {
|
||||
const p = debugLogPath(providerId);
|
||||
if (existsSync(p)) writeFileSync(p, "", "utf8");
|
||||
} catch (err) {
|
||||
console.warn(`[omniroute-plugin] debugLogClear failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a fetch function to capture request/response pairs into the debug
|
||||
* JSONL log. Honours the `featureDefault` opt-in flag and the on-disk
|
||||
* runtime toggle (`debugLogEnabled`).
|
||||
*/
|
||||
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 instanceof Request
|
||||
? input.url
|
||||
: String(input);
|
||||
const method = (init?.method ?? (typeof input === "string" ? "GET" : (input as Request).method ?? "GET")).toUpperCase();
|
||||
const reqHeaders: Record<string, string> = {};
|
||||
if (input instanceof Request) {
|
||||
input.headers.forEach((v, k) => (reqHeaders[k] = v));
|
||||
}
|
||||
if (init?.headers) {
|
||||
const h = init.headers;
|
||||
if (h instanceof Headers) h.forEach((v, k) => (reqHeaders[k] = v));
|
||||
else if (Array.isArray(h)) for (const [k, v] of h) reqHeaders[k] = v;
|
||||
else Object.assign(reqHeaders, h);
|
||||
}
|
||||
let reqBody: unknown = undefined;
|
||||
if (init?.body) {
|
||||
if (typeof init.body === "string") {
|
||||
try {
|
||||
reqBody = JSON.parse(init.body);
|
||||
} catch {
|
||||
reqBody = init.body.slice(0, 4096);
|
||||
}
|
||||
} else {
|
||||
reqBody = "[non-string body]";
|
||||
}
|
||||
} else if (input instanceof Request) {
|
||||
try {
|
||||
const clonedReq = input.clone();
|
||||
const text = await clonedReq.text();
|
||||
try {
|
||||
reqBody = JSON.parse(text);
|
||||
} catch {
|
||||
reqBody = text.slice(0, 4096);
|
||||
}
|
||||
} catch {
|
||||
reqBody = "[body unreadable]";
|
||||
}
|
||||
}
|
||||
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;
|
||||
try {
|
||||
const clone = res.clone();
|
||||
const ct = clone.headers.get("content-type") ?? "";
|
||||
if (ct.includes("application/json")) {
|
||||
resBody = await clone.json();
|
||||
} else if (ct.includes("text/event-stream")) {
|
||||
resBody = "[stream]";
|
||||
} else if (ct.includes("text/")) {
|
||||
const txt = await clone.text();
|
||||
resBody = txt.length > 4096 ? txt.slice(0, 4096) + "...[truncated]" : txt;
|
||||
} else {
|
||||
resBody = `[${ct || "unknown"} body, status ${res.status}]`;
|
||||
}
|
||||
} catch {
|
||||
resBody = "[body unparseable]";
|
||||
}
|
||||
debugLogAppend({
|
||||
reqId,
|
||||
providerId,
|
||||
ts: t0,
|
||||
url,
|
||||
method,
|
||||
reqHeaders,
|
||||
reqBody,
|
||||
resStatus: res.status,
|
||||
resHeaders,
|
||||
resBody,
|
||||
durationMs,
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - t0;
|
||||
debugLogAppend({
|
||||
reqId,
|
||||
providerId,
|
||||
ts: t0,
|
||||
url,
|
||||
method,
|
||||
reqHeaders,
|
||||
reqBody,
|
||||
resStatus: null,
|
||||
resHeaders: {},
|
||||
resBody: undefined,
|
||||
durationMs,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
export const noopDiskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
|
||||
export type OmniRouteReadAuthJson = () => Promise<AuthJsonShape | undefined | null>;
|
||||
|
||||
291
@omniroute/opencode-plugin/tests/fork-features.test.ts
Normal file
291
@omniroute/opencode-plugin/tests/fork-features.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Tests for the 3 mrmm-fork features backported to @omniroute/opencode-plugin:
|
||||
*
|
||||
* 1. `normaliseFreeLabel` — free-tier model display names get a consistent
|
||||
* `[Free] ` prefix instead of trailing "(Free)" or ad-hoc "free" words.
|
||||
*
|
||||
* 2. `resolveApiBlock` — per-provider-prefix API format routing. Anthropic
|
||||
* prefixes (`cc/`, `claude/`, `anthropic/`, `kiro/`, `kr/`) get the
|
||||
* Anthropic SDK block; everything else gets OpenAI-compat.
|
||||
*
|
||||
* 3. `debugLog` — JSONL request/response capture, gated by
|
||||
* `features.debugLog` and togglable at runtime via
|
||||
* `debugLogEnabled/SetEnabled`.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
normaliseFreeLabel,
|
||||
resolveApiBlock,
|
||||
DEFAULT_ANTHROPIC_PREFIXES,
|
||||
ensureV1Suffix,
|
||||
debugLogEnabled,
|
||||
debugLogSetEnabled,
|
||||
debugLogClear,
|
||||
debugLogRead,
|
||||
debugLogAppend,
|
||||
createDebugLoggingFetch,
|
||||
DebugLogEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ── 1. normaliseFreeLabel ────────────────────────────────────────────────────
|
||||
|
||||
test("normaliseFreeLabel: '(Free)' suffix becomes [Free] prefix", () => {
|
||||
assert.equal(normaliseFreeLabel("GPT-4.1 (Free)"), "[Free] GPT-4.1");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: trailing ' Free' word becomes [Free] prefix", () => {
|
||||
assert.equal(
|
||||
normaliseFreeLabel("DeepSeek V4 Flash Free"),
|
||||
"[Free] DeepSeek V4 Flash"
|
||||
);
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: trailing '-free' (hyphen) becomes [Free] prefix", () => {
|
||||
assert.equal(normaliseFreeLabel("Llama 4 Scout-free"), "[Free] Llama 4 Scout");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: case-insensitive (FREE, Free, free all match)", () => {
|
||||
assert.equal(normaliseFreeLabel("Model A FREE"), "[Free] Model A");
|
||||
assert.equal(normaliseFreeLabel("Model A free"), "[Free] Model A");
|
||||
assert.equal(normaliseFreeLabel("Model A Free"), "[Free] Model A");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: names without 'free' pass through unchanged", () => {
|
||||
assert.equal(normaliseFreeLabel("Claude 4.7 Opus"), "Claude 4.7 Opus");
|
||||
assert.equal(normaliseFreeLabel("GPT-5"), "GPT-5");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: 'free' in the middle of a name is NOT rewritten", () => {
|
||||
// Only trailing/standalone "free" markers count; embedded "freedom" stays
|
||||
assert.equal(
|
||||
normaliseFreeLabel("Freedom Model"),
|
||||
"Freedom Model"
|
||||
);
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: empty / whitespace-only inputs are handled", () => {
|
||||
// Empty input returns empty; pure whitespace input passes through (no Free marker)
|
||||
assert.equal(normaliseFreeLabel(""), "");
|
||||
assert.equal(normaliseFreeLabel(" "), " ");
|
||||
});
|
||||
|
||||
// ── 2. resolveApiBlock ───────────────────────────────────────────────────────
|
||||
|
||||
test("resolveApiBlock: cc/* models get the Anthropic SDK block (no /v1)", () => {
|
||||
const block = resolveApiBlock("cc/claude-opus-4-7", "https://api.example.com");
|
||||
assert.equal(block.id, "anthropic");
|
||||
assert.equal(block.npm, "@ai-sdk/anthropic");
|
||||
assert.equal(block.url, "https://api.example.com"); // NO /v1 suffix
|
||||
});
|
||||
|
||||
test("resolveApiBlock: claude/*, anthropic/*, kiro/*, kr/* all route to Anthropic", () => {
|
||||
for (const id of [
|
||||
"claude/claude-opus-4-7",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"kiro/claude-sonnet-4-5",
|
||||
"kr/claude-opus-4-6",
|
||||
]) {
|
||||
const block = resolveApiBlock(id, "https://api.example.com");
|
||||
assert.equal(block.id, "anthropic", `${id} should route to Anthropic`);
|
||||
assert.equal(block.npm, "@ai-sdk/anthropic");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveApiBlock: non-Anthropic models get OpenAI-compat with /v1", () => {
|
||||
const block = resolveApiBlock("gpt-4o", "https://api.example.com");
|
||||
assert.equal(block.id, "openai-compatible");
|
||||
assert.equal(block.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(block.url, "https://api.example.com/v1");
|
||||
});
|
||||
|
||||
test("resolveApiBlock: user can override anthropicPrefixes to add custom prefixes", () => {
|
||||
const block = resolveApiBlock("myproxy/claude-opus", "https://api.example.com", {
|
||||
anthropicPrefixes: ["myproxy"],
|
||||
});
|
||||
assert.equal(block.id, "anthropic");
|
||||
assert.equal(block.npm, "@ai-sdk/anthropic");
|
||||
});
|
||||
|
||||
test("resolveApiBlock: empty anthropicPrefixes forces OpenAI-compat for everything", () => {
|
||||
const block = resolveApiBlock("cc/claude-opus", "https://api.example.com", {
|
||||
anthropicPrefixes: [],
|
||||
});
|
||||
assert.equal(block.id, "openai-compatible");
|
||||
});
|
||||
|
||||
test("resolveApiBlock: baseURL that already ends in /v1 is not double-suffixed (OpenAI path)", () => {
|
||||
const block = resolveApiBlock("gpt-4o", "https://api.example.com/v1");
|
||||
assert.equal(block.url, "https://api.example.com/v1"); // idempotent
|
||||
});
|
||||
|
||||
test("resolveApiBlock: model id without '/' uses the id as prefix", () => {
|
||||
const block = resolveApiBlock("claude-opus-4-7", "https://api.example.com");
|
||||
// The whole id is the prefix, which doesn't match "cc"/"claude" etc.
|
||||
// So it falls through to OpenAI-compat.
|
||||
assert.equal(block.id, "openai-compatible");
|
||||
});
|
||||
|
||||
test("DEFAULT_ANTHROPIC_PREFIXES: contains the canonical Anthropic aliases", () => {
|
||||
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, [
|
||||
"cc",
|
||||
"claude",
|
||||
"anthropic",
|
||||
"kiro",
|
||||
"kr",
|
||||
]);
|
||||
});
|
||||
|
||||
test("ensureV1Suffix: idempotent for URLs that already end in /v1", () => {
|
||||
assert.equal(ensureV1Suffix("https://api.example.com/v1"), "https://api.example.com/v1");
|
||||
assert.equal(
|
||||
ensureV1Suffix("https://api.example.com/v1/"),
|
||||
"https://api.example.com/v1" // trailing slash is stripped
|
||||
);
|
||||
});
|
||||
|
||||
test("ensureV1Suffix: appends /v1 when missing", () => {
|
||||
assert.equal(ensureV1Suffix("https://api.example.com"), "https://api.example.com/v1");
|
||||
assert.equal(ensureV1Suffix("https://api.example.com/"), "https://api.example.com/v1");
|
||||
});
|
||||
|
||||
// ── 3. debugLog ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("debugLog: default state is disabled", () => {
|
||||
debugLogClear("test-provider-disabled-default");
|
||||
assert.equal(debugLogEnabled("test-provider-disabled-default"), false);
|
||||
});
|
||||
|
||||
test("debugLogSetEnabled + debugLogEnabled: roundtrip", () => {
|
||||
debugLogSetEnabled("test-provider-toggle", true);
|
||||
assert.equal(debugLogEnabled("test-provider-toggle"), true);
|
||||
debugLogSetEnabled("test-provider-toggle", false);
|
||||
assert.equal(debugLogEnabled("test-provider-toggle"), false);
|
||||
});
|
||||
|
||||
test("debugLogAppend + debugLogRead: roundtrip preserves entry shape", () => {
|
||||
const providerId = "test-provider-readroundtrip";
|
||||
debugLogClear(providerId);
|
||||
const entry: DebugLogEntry = {
|
||||
reqId: "req-1",
|
||||
providerId,
|
||||
ts: 1700000000000,
|
||||
url: "https://api.example.com/v1/chat",
|
||||
method: "POST",
|
||||
reqHeaders: { "content-type": "application/json" },
|
||||
reqBody: { model: "gpt-4o", messages: [] },
|
||||
resStatus: 200,
|
||||
resHeaders: { "content-type": "application/json" },
|
||||
resBody: { choices: [] },
|
||||
durationMs: 42,
|
||||
};
|
||||
debugLogAppend(entry);
|
||||
const read = debugLogRead(providerId, 10);
|
||||
assert.equal(read.length, 1);
|
||||
assert.deepEqual(read[0], entry);
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: passes through when disabled", async () => {
|
||||
const providerId = "test-provider-passthrough";
|
||||
debugLogClear(providerId);
|
||||
debugLogSetEnabled(providerId, false);
|
||||
const calls: unknown[] = [];
|
||||
const inner: typeof fetch = async (input) => {
|
||||
calls.push(input);
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, false);
|
||||
const res = await wrapped("https://api.example.com/v1/chat");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(calls.length, 1);
|
||||
// No log entry should be written when disabled
|
||||
assert.equal(debugLogRead(providerId).length, 0);
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: captures request/response when enabled", async () => {
|
||||
const providerId = "test-provider-captures";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
const res = await wrapped("https://api.example.com/v1/chat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "gpt-4o" }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].method, "POST");
|
||||
assert.equal(entries[0].resStatus, 200);
|
||||
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
|
||||
assert.deepEqual(entries[0].reqBody, { model: "gpt-4o" });
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: records error without crashing the wrapped fetch", async () => {
|
||||
const providerId = "test-provider-error";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () => {
|
||||
throw new Error("network down");
|
||||
};
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
await assert.rejects(wrapped("https://api.example.com/v1/chat"), /network down/);
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].resStatus, null);
|
||||
assert.equal(entries[0].error, "network down");
|
||||
});
|
||||
|
||||
// ── Regression tests for the 3 HIGH-priority bot review fixes ───────────────
|
||||
|
||||
test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')", async () => {
|
||||
const providerId = "test-provider-url-input";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response("ok", { status: 200 });
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
await wrapped(new URL("https://api.example.com/v1/chat"));
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
|
||||
assert.notEqual(entries[0].url, undefined);
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: Request object input captures URL and headers", async () => {
|
||||
const providerId = "test-provider-request-input";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response("ok", { status: 200 });
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
const req = new Request("https://api.example.com/v1/chat", {
|
||||
method: "POST",
|
||||
headers: { "x-test": "yes" },
|
||||
});
|
||||
await wrapped(req);
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
|
||||
assert.equal(entries[0].reqHeaders["x-test"], "yes");
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: SSE response is NOT buffered (resBody is the stream marker)", async () => {
|
||||
const providerId = "test-provider-sse";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response("data: hello\n\n", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
const res = await wrapped("https://api.example.com/v1/stream");
|
||||
// The response body must remain readable downstream
|
||||
const txt = await res.text();
|
||||
assert.equal(txt, "data: hello\n\n");
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].resBody, "[stream]", "SSE responses must not be buffered");
|
||||
});
|
||||
Reference in New Issue
Block a user