Compare commits

...

4 Commits

Author SHA1 Message Date
diegosouzapw
9a18a6eb5a merge origin/release/v3.8.51 into #12330 after #12327 drain 2026-09-01 16:29:23 -03:00
Diego Rodrigues de Sa e Souza
1586476183 fix(release): drain the 2026-09-01 base-red window — passthrough usage regression + radarPage i18n keys (#12327)
* fix(sse): forward the upstream's real trailing usage in passthrough; estimate only at flush

#12151 injected estimated usage into the finish chunk and dropped the real
trailing usage block that genuine OpenAI upstreams send afterwards — metered
clients got estimates instead of real token counts. The estimate now leaves
via a canonical usage-only chunk at flush, only when the upstream stayed
silent; a real trailing block is forwarded verbatim and wins. The tool_calls
finish_reason normalization now materializes its own rewrite (it piggybacked
on the removed finish-time rewrite), and the dead collectSSE helper goes with
it (subsumes #12324).

* fix(i18n): seed the radarPage limits/training keys the #12320 UI already consumes

RadarCatalogTable.tsx references radarPage.colLimits / trainsOnPrompts /
trainsOnPromptsHelp but #12320 never added them to en.json, so the EN
fallback could not resolve the __MISSING__ markers across 42 locales.
Real translations for pt-BR and vi; the rest resolve via the EN fallback.

* fix(sse): carry the chat stream id into flush-time synthetic chunks

The estimated usage-only chunk emitted at flush used passthroughResponsesId,
which is only ever set on the Responses path — on the chat path the synthetic
chunk shipped id: null, breaking the string-id invariant pinned by
stream-numeric-ids.test.ts. Track the upstream chat-completion id in the
passthrough loop and reuse it (falling back to the Responses id, then a
generated one). Sibling sweep: 74 files importing utils/stream — 603/603.
2026-09-01 16:17:48 -03:00
Dizzle
c9f9b6274e feat(providers): expose usage-supported in provider plugin manifest (#12214)
The provider plugin manifest already exposed usage-fetch (40 providers, #11903); this publishes the second capability, usage-supported, so integrators can tell without reading TypeScript whether the server usage routes accept a provider. #11903 closed #11722 after shipping only half of it and said so at the time — this is the follow-up it promised.

The two scopes genuinely differ and the docs now say how: usage-fetch resolves on id or alias (the dispatcher accepts both), usage-supported on id alone, because the runtime guard does a plain USAGE_SUPPORTED_PROVIDERS.includes(providerId) with no alias resolution. 42 providers carry both tags, 4 carry only usage-fetch (opencode, opencode-zen, openrouter, xai) and 3 only usage-supported (adobe-firefly, firefly, xiaomi-mimo-token-plan) — 7 measured differences, so neither tag implies the other. No list mutation, no new route, schemaVersion stays 1.

USAGE_SUPPORTED_PROVIDERS moved out of src/shared/constants/providers.ts into an import-free leaf at open-sse/services/usage/supportedProviders.ts, keeping the manifest's import graph light — the same move fetcherProviders.ts got in #11903, landed on the correct side of the workspace boundary.

Base note: the branch forked 46 commits before kilocode joined the list, so a wholesale take of its providers.ts would have silently dropped that id. Verified against the current release tip before merging — both sides hold the same 46 ids, nothing lost.

Verified on the current tip: typecheck:core clean, check:cycles OK across 417 files (the import-free-leaf claim holds), and 63/63 focused tests across provider-plugin-manifest, usage-fetcher-registration-coverage, adobe-firefly and agentrouter-quota-dashboard-rendering.

Thanks @maxmad64bis — and for finishing the half of #11722 that was left open rather than letting it sit.
2026-09-01 16:02:46 -03:00
千乘妍 (Xiaoyaner)
9d5ab8ccbf fix(combos): clear LKGP pins when a combo is deleted
deleteCombo() removed the combos row but left the combo's LKGP pins in
key_value. Pins are keyed by `${comboName}:${modelId}`, so once the combo
is gone the rows are unreachable: the combo 404s, clearLKGP() needs a
modelId the caller no longer has, and clearAllLKGP() is far too broad.

Provider-connection deletion already cleans up its pins via
deleteLKGPByConnectionIds() (#8887). This applies the same discipline to
combo deletion with deleteLKGPByComboName(), which also invalidates the
read cache so a stale pin cannot be served for the rest of the TTL.

Closes #12326
2026-09-02 00:18:25 +08:00
16 changed files with 466 additions and 113 deletions

View File

@@ -0,0 +1 @@
- **feat(providers):** the provider plugin manifest now also advertises a `usage-supported` capability for the 46 providers whose usage API is accepted by the server and Dashboard routes, so integrators can distinguish "the server will serve quota for this provider" from "a fetcher is wired" without reading TypeScript. Discovery only — no fetcher or quota change. `usage-fetch` resolves on id or alias (the usage dispatcher accepts both); `usage-supported` resolves on id alone, matching the runtime guard `USAGE_SUPPORTED_PROVIDERS.includes(providerId)`. `USAGE_SUPPORTED_PROVIDERS` moved to a zero-dependency leaf (`open-sse/services/usage/supportedProviders.ts`) and is re-exported from `providers.ts`, mirroring the `fetcherProviders` leaf from #11903 and keeping the manifest a light module. ([#12214](https://github.com/diegosouzapw/OmniRoute/pull/12214)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(combos):** deleting a combo now clears its persisted LKGP pins instead of leaving unreachable `key_value` rows behind ([#12330](https://github.com/diegosouzapw/OmniRoute/pull/12330))

View File

@@ -48,7 +48,7 @@ The manifest contains:
- JSON-safe model metadata such as context length, vision/reasoning flags, and
unsupported params
- capability tags including `apikey`, `oauth`, `custom-executor`,
`passthrough-models`, `responses`, `sidecar-candidate`, and `usage-fetch`
`passthrough-models`, `responses`, `sidecar-candidate`, `usage-fetch`, and `usage-supported`
The manifest intentionally excludes:
@@ -74,6 +74,7 @@ re-reading the TypeScript sources.
| `custom-executor` | Runs a non-default executor, so it stays on the TypeScript path. |
| `sidecar-candidate` | Mirrors `sidecar.eligible` — safe to consider for sidecar import. |
| `usage-fetch` | Has a wired usage or quota fetcher (`getUsageForProvider`). |
| `usage-supported` | The usage API accepts this provider (`isSupportedUsageConnection`). |
`usage-fetch` is discovery only. It reports that OmniRoute knows how to read usage for the
provider; it does not activate fetching, change quota semantics, or imply that the
@@ -86,6 +87,16 @@ with aliases and is slightly longer than the number of tagged providers: entries
not chat providers in the manifest registry (for example the `firecrawl` search provider
and the `amazon-q` ACP provider) have no manifest entry to tag.
`usage-supported` answers whether the server and Dashboard usage routes accept a connection
for the provider. It mirrors `isSupportedUsageConnection()` (`src/lib/usage/providerLimits.ts`)
and `supportsProviderQuota()` (`src/shared/utils/providerQuotaVisibility.ts`), both gated by
`USAGE_SUPPORTED_PROVIDERS` (`open-sse/services/usage/supportedProviders.ts`). Unlike
`usage-fetch`, it is emitted on the provider id alone — the runtime guard does
`USAGE_SUPPORTED_PROVIDERS.includes(providerId)` with no alias resolution, so the manifest
keeps the same rule. The two tags have different perimeters: 4 providers carry only
`usage-fetch` (`opencode`, `opencode-zen`, `openrouter`, `xai`) and 1 carries only
`usage-supported` (`xiaomi-mimo-token-plan`), so one does not imply the other.
## Sidecar Use
Sidecars should treat `sidecar.eligible` as a conservative candidate signal, not

View File

@@ -1,5 +1,6 @@
import type { RegistryEntry, RegistryModel } from "./providers/shared.ts";
import { USAGE_FETCHER_PROVIDERS } from "../services/usage/fetcherProviders.ts";
import { USAGE_SUPPORTED_PROVIDERS } from "../services/usage/supportedProviders.ts";
export type ProviderPluginCapability =
| "apikey"
@@ -8,7 +9,8 @@ export type ProviderPluginCapability =
| "passthrough-models"
| "responses"
| "sidecar-candidate"
| "usage-fetch";
| "usage-fetch"
| "usage-supported";
export interface ProviderPluginModel {
id: string;
@@ -66,6 +68,15 @@ const SIDECAR_COMPATIBLE_EXECUTORS = new Set(["default"]);
*/
const USAGE_FETCHER_PROVIDER_SET = new Set<string>(USAGE_FETCHER_PROVIDERS);
/**
* Providers whose usage API is accepted by dashboard/server routes (#10078).
* Unlike USAGE_FETCHER_PROVIDERS this gate is checked with a plain
* `USAGE_SUPPORTED_PROVIDERS.includes(providerId)` — no alias resolution —
* so the manifest must emit on the identifier alone to stay faithful to the
* runtime guard.
*/
const USAGE_SUPPORTED_PROVIDER_SET = new Set<string>(USAGE_SUPPORTED_PROVIDERS);
function compactObject<T extends Record<string, unknown>>(value: T): Partial<T> {
return Object.fromEntries(
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined)
@@ -142,6 +153,9 @@ function capabilitiesFor(entry: RegistryEntry, eligible: boolean): ProviderPlugi
) {
capabilities.add("usage-fetch");
}
if (USAGE_SUPPORTED_PROVIDER_SET.has(entry.id)) {
capabilities.add("usage-supported");
}
return [...capabilities].sort();
}

View File

@@ -0,0 +1,79 @@
/**
* usage/supportedProviders.ts — registration list of providers whose usage/quota
* API is accepted by the dashboard and server routes.
*
* Extracted from `src/shared/constants/providers.ts` so that light consumers —
* the provider-plugin manifest (`config/providerPluginManifest.ts`) above all —
* can read the list without pulling the ~12-module provider registry, and
* without an open-sse module reaching across the workspace boundary into
* `src/` (the open-sse typecheck gate forbids open-sse → src imports). Same
* pattern as `fetcherProviders.ts` (#11903): pure data — no imports, no module
* state — so it cannot introduce a cycle. `src/shared/constants/providers.ts`
* re-exports the value, so every existing `@/shared/constants/providers`
* import path keeps working unchanged.
*
* Typed `readonly string[]` (not `as const`): the dashboard/server gates call
* `USAGE_SUPPORTED_PROVIDERS.includes(providerId)` with a plain `string`, which
* a literal-tuple type would reject (TS2345).
*/
// Providers that support usage/quota API
export const USAGE_SUPPORTED_PROVIDERS: readonly string[] = [
"antigravity",
"agy",
"kiro",
"amazon-q",
"github",
"codex",
"claude",
"cursor",
"qoder",
"kimi-coding",
"kimi-coding-apikey",
"glm",
"glm-cn",
"zai",
"glmt",
"opencode-go",
"ollama-cloud",
"minimax",
"minimax-cn",
"crof",
"nanogpt",
"deepseek",
"xiaomi-mimo",
"xiaomi-mimo-token-plan",
"vertex",
"vertex-partner",
"codebuddy-cn",
// PromptQL playground credits (getCreditSummary → USD micros)
"promptql",
"pql",
// Adobe Firefly web (cookie/JWT as apikey) — GET firefly.adobe.io/v1/credits/balance
"adobe-firefly",
"firefly",
"hyperagent",
"ha",
// xAI OAuth (Grok) weekly quota (id + public alias, same pattern as ha/agy)
"xai-oauth",
"xao",
// Grok Build subscription, billing credits, and auto top-up status
"grok-cli",
// Firecrawl team credits (GET /v2/team/credit-usage)
"firecrawl",
// Volcano Ark Plan subscriptions (agent-plan / coding-plan)
"volcengine-agent-plan",
"volcengine-coding-plan",
// Command Code credits + 5h/weekly rolling windows
"command-code",
"conol-web",
"cnl",
// Alibaba Coding Plan triple-window quota (#9603 UI gap — fetcher existed, list entry missing)
"bailian-coding-plan",
// Qwen Cloud / Model Studio personal Token Plan (cookie-authenticated console gateway)
"qwen-cloud-token-plan",
// AgentRouter (New-API) console balance quota (consoleApiKey + newApiUserId)
"agentrouter",
// Kilo Code personal USD balance (GET /api/profile/balance, existing OAuth token)
"kilocode",
];

View File

@@ -771,6 +771,7 @@ export function createSSEStream(options: StreamOptions = {}) {
const passthroughResponsesOutputItems: unknown[] = [];
const passthroughResponsesPendingFunctionCalls = new Map<string, JsonRecord>();
let passthroughResponsesId: string | null = null;
let passthroughLastChatId: string | null = null;
let passthroughResponsesCurrentFunctionCallKey: string | null = null;
const passthroughResponsesReasoningSummarySeen = new Set<string>();
// #6199 — commentary-phase items announced via `response.output_item.added` are
@@ -1955,6 +1956,16 @@ export function createSSEStream(options: StreamOptions = {}) {
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
// Remember the upstream's chat-completion id so synthetic chunks
// emitted at flush (e.g. the estimated usage-only chunk) carry the
// stream's real string id instead of null on the chat path
// (passthroughResponsesId is only ever set on the Responses path).
if (typeof parsed.id === "string" && parsed.id) {
passthroughLastChatId = parsed.id;
} else if (typeof parsed.id === "number") {
passthroughLastChatId = String(parsed.id);
}
if (isFinishChunk) {
passthroughSawFinishReason = true;
}
@@ -1973,28 +1984,21 @@ export function createSSEStream(options: StreamOptions = {}) {
parsed.choices[0].finish_reason !== "tool_calls"
) {
parsed.choices[0].finish_reason = "tool_calls";
// If we modify it, we must output the modified object
if (!injectedUsage && hasValidUsage(parsed.usage)) {
output = `data: ${JSON.stringify(parsed)}\n\n`;
injectedUsage = true;
}
// If we modify it, we must output the modified object. This used to
// piggyback on the estimated-usage rewrite below; with the estimate
// moved to flush() (#12151 follow-up) the rewrite must happen here.
// injectedUsage doubles as the "output already rewritten" latch —
// without it the raw line overwrites this rewrite further down.
output = `data: ${JSON.stringify(parsed)}\n\n`;
injectedUsage = true;
}
if (
isFinishChunk &&
!passthroughForwardedUsage &&
!hasValidUsage(parsed.usage) &&
!hasValidUsage(usage) &&
totalContentLength > 0
) {
const estimated = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI);
if (hasValidUsage(estimated)) {
parsed.usage = filterUsageForFormat(estimated, sourceFormat || FORMATS.OPENAI);
output = `data: ${JSON.stringify(parsed)}\n\n`;
usage = estimated;
passthroughForwardedUsage = true;
injectedUsage = true;
}
} else if (isFinishChunk && hasValidUsage(usage) && !passthroughForwardedUsage) {
// #12151 follow-up: do NOT inject estimated usage into the finish chunk.
// A genuine OpenAI upstream sends its usage in a trailing empty-choices
// chunk AFTER the finish; estimating here marked passthroughForwardedUsage
// and made the real trailing block get dropped in favor of the estimate
// (billing regression pinned by tests/unit/stream-utils.test.ts). The
// estimate is now emitted in flush(), only when the upstream stayed silent.
if (isFinishChunk && hasValidUsage(usage) && !passthroughForwardedUsage) {
const buffered = addBufferToUsage(usage);
parsed.usage = filterUsageForFormat(buffered, sourceFormat || FORMATS.OPENAI);
output = `data: ${JSON.stringify(parsed)}\n\n`;
@@ -2510,6 +2514,30 @@ export function createSSEStream(options: StreamOptions = {}) {
forward(controller, encoder.encode(finishOutput));
clientPayloadCollector.push(syntheticFinishChunk);
}
// #12151: upstream never reported usage — emit the estimate as a
// canonical OpenAI trailing usage-only chunk (empty choices) before
// [DONE], so metered clients still see token counts. When the
// upstream DID send usage (trailing or in-band), it was forwarded
// already and passthroughForwardedUsage guards this off.
if (
shouldEmitDoneTerminator &&
!passthroughForwardedUsage &&
hasValidUsage(usage)
) {
const usageOnlyChunk = {
id: passthroughLastChatId ?? passthroughResponsesId ?? `chatcmpl-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [],
usage: filterUsageForFormat(usage, sourceFormat || FORMATS.OPENAI),
};
const usageOutput = `data: ${JSON.stringify(usageOnlyChunk)}\n\n`;
reqLogger?.appendConvertedChunk?.(usageOutput);
forward(controller, encoder.encode(usageOutput));
clientPayloadCollector.push(usageOnlyChunk);
passthroughForwardedUsage = true;
}
await emitFinalSseMetadata(controller, usage);
doneSent = true;
if (shouldEmitDoneTerminator) {

View File

@@ -13381,6 +13381,9 @@
"colProvider": "Provider",
"colModel": "Model",
"colQuota": "Quota",
"colLimits": "Limits",
"trainsOnPrompts": "Trains on prompts",
"trainsOnPromptsHelp": "This provider discloses that it may use your prompts to train models",
"colContext": "Context",
"colCapabilities": "Capabilities",
"colTos": "ToS Risk",

View File

@@ -13382,12 +13382,12 @@
"colProvider": "Provedor",
"colModel": "Modelo",
"colQuota": "Cota",
"colLimits": "Limites",
"trainsOnPrompts": "Treina com prompts",
"trainsOnPromptsHelp": "Este provedor declara que pode usar seus prompts para treinar modelos",
"colContext": "Contexto",
"colCapabilities": "Capacidades",
"colTos": "Risco ToS",
"colLimits": "__MISSING__:Rate limits",
"trainsOnPrompts": "__MISSING__:Trains on prompts",
"trainsOnPromptsHelp": "__MISSING__:This provider's terms state it may train on the prompts you send. Models without this badge either state they do not, or do not document it — an absent statement is not a guarantee.",
"newBadge": "novo",
"setupGuide": "Guia de configuração",
"disabledByFeed": "Desativado pelo feed Radar",

View File

@@ -13382,12 +13382,12 @@
"colProvider": "Nhà cung cấp",
"colModel": "Mô hình",
"colQuota": "Hạn ngạch",
"colLimits": "Giới hạn",
"trainsOnPrompts": "Huấn luyện bằng prompt",
"trainsOnPromptsHelp": "Nhà cung cấp này công bố có thể dùng prompt của bạn để huấn luyện mô hình",
"colContext": "Ngữ cảnh",
"colCapabilities": "Khả năng",
"colTos": "Rủi ro ToS",
"colLimits": "__MISSING__:Rate limits",
"trainsOnPrompts": "__MISSING__:Trains on prompts",
"trainsOnPromptsHelp": "__MISSING__:This provider's terms state it may train on the prompts you send. Models without this badge either state they do not, or do not document it — an absent statement is not a guarantee.",
"newBadge": "mới",
"setupGuide": "Hướng dẫn thiết lập",
"disabledByFeed": "Bị vô hiệu hóa bởi nguồn cấp dữ liệu Radar",

View File

@@ -349,8 +349,20 @@ export async function reorderCombos(comboIds: string[]): Promise<ComboReorderRes
export async function deleteCombo(id: string) {
const db = getDbInstance();
const combo = db.prepare("SELECT name FROM combos WHERE id = ?").get(id) as
{ name?: string } | undefined;
const result = db.prepare("DELETE FROM combos WHERE id = ?").run(id);
if (result.changes === 0) return false;
if (combo?.name) {
try {
const { deleteLKGPByComboName } = await import("../settings/lkgp");
await deleteLKGPByComboName(combo.name);
} catch (error) {
console.error("Failed to clean up LKGP pins for deleted combo:", error);
}
}
return true;
}

View File

@@ -840,6 +840,7 @@ export {
setLKGP,
clearAllLKGP,
clearLKGP,
deleteLKGPByComboName,
deleteLKGPByConnectionIds,
} from "./settings/lkgp";

View File

@@ -4,6 +4,14 @@
import { getDbInstance } from "../core";
/**
* Escape SQLite `LIKE` wildcards so a combo name containing `%` or `_` cannot
* widen the prefix match into unrelated combos' pins.
*/
function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
export interface LKGPRecord {
provider: string;
connectionId?: string;
@@ -67,6 +75,40 @@ export async function clearLKGP(comboName: string, modelId: string): Promise<voi
invalidateCachedLKGP(key);
}
/**
* Delete every persisted LKGP pin belonging to a combo. Pins are keyed by
* `${comboName}:${modelId}`, so deleting a combo leaves its pins addressable by
* a name that no longer resolves — `clearAllLKGP()` is too broad and
* `clearLKGP()` needs a modelId the caller no longer knows. Combo delete paths
* call this so the pins die with their combo instead of accumulating as
* unreachable rows.
*/
export async function deleteLKGPByComboName(comboName: string): Promise<number> {
if (!comboName) return 0;
const db = getDbInstance();
const prefix = `${comboName}:`;
const rows = db
.prepare("SELECT key FROM key_value WHERE namespace = 'lkgp' AND key LIKE ? ESCAPE '\\'")
.all(`${escapeLikePattern(prefix)}%`) as Array<{ key?: string }>;
const staleKeys = rows.map((row) => row?.key).filter((key): key is string => Boolean(key));
if (staleKeys.length === 0) return 0;
const deleteStatement = db.prepare("DELETE FROM key_value WHERE namespace = 'lkgp' AND key = ?");
for (const key of staleKeys) {
deleteStatement.run(key);
}
const { invalidateCachedLKGP } = await import("../readCache");
for (const key of staleKeys) {
invalidateCachedLKGP(key);
}
return staleKeys.length;
}
/**
* Delete persisted LKGP pins whose connectionId references a removed provider
* connection (#8887). A pin persisted by `setLKGP()` carries the connection it

View File

@@ -482,66 +482,7 @@ export const ID_TO_ALIAS = new Proxy({} as Record<string, string>, {
},
});
// Providers that support usage/quota API
export const USAGE_SUPPORTED_PROVIDERS = [
"antigravity",
"agy",
"kiro",
"amazon-q",
"github",
"codex",
"claude",
"cursor",
"qoder",
"kimi-coding",
"kimi-coding-apikey",
"glm",
"glm-cn",
"zai",
"glmt",
"opencode-go",
"ollama-cloud",
"minimax",
"minimax-cn",
"crof",
"nanogpt",
"deepseek",
"xiaomi-mimo",
"xiaomi-mimo-token-plan",
"vertex",
"vertex-partner",
"codebuddy-cn",
// PromptQL playground credits (getCreditSummary → USD micros)
"promptql",
"pql",
// Adobe Firefly web (cookie/JWT as apikey) — GET firefly.adobe.io/v1/credits/balance
"adobe-firefly",
"firefly",
"hyperagent",
"ha",
// xAI OAuth (Grok) weekly quota (id + public alias, same pattern as ha/agy)
"xai-oauth",
"xao",
// Grok Build subscription, billing credits, and auto top-up status
"grok-cli",
// Firecrawl team credits (GET /v2/team/credit-usage)
"firecrawl",
// Volcano Ark Plan subscriptions (agent-plan / coding-plan)
"volcengine-agent-plan",
"volcengine-coding-plan",
// Command Code credits + 5h/weekly rolling windows
"command-code",
"conol-web",
"cnl",
// Alibaba Coding Plan triple-window quota (#9603 UI gap — fetcher existed, list entry missing)
"bailian-coding-plan",
// Qwen Cloud / Model Studio personal Token Plan (cookie-authenticated console gateway)
"qwen-cloud-token-plan",
// AgentRouter (New-API) console balance quota (consoleApiKey + newApiUserId)
"agentrouter",
// Kilo Code personal USD balance (GET /api/profile/balance, existing OAuth token)
"kilocode",
];
export { USAGE_SUPPORTED_PROVIDERS } from "@omniroute/open-sse/services/usage/supportedProviders.ts";
// ── Zod validation, lazily on first AI_PROVIDERS access (perf: skips the walk
// for processes that never touch AI_PROVIDERS, e.g. short-lived CLI commands) ──

View File

@@ -0,0 +1,152 @@
/**
* Issue #12326 — deleting a combo must remove the LKGP pins keyed by its name
* without disturbing surviving combos' pins.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-lkgp-12326-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const comboRepo = await import("../../src/lib/db/repositories/sqliteComboRepository.ts");
const lkgpDb = await import("../../src/lib/db/settings/lkgp.ts");
const readCache = await import("../../src/lib/db/readCache.ts");
async function resetStorage() {
core.resetDbInstance();
for (let attempt = 0; attempt < 10; attempt++) {
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
break;
} catch (error: unknown) {
const code =
error && typeof error === "object" && "code" in error
? String((error as { code?: unknown }).code)
: "";
if ((code === "EBUSY" || code === "EPERM") && attempt < 9) {
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
continue;
}
throw error;
}
}
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
async function createCombo(name: string): Promise<string> {
const combo = await comboRepo.createCombo({
name,
models: [{ provider: "berry", model: "model-x" }],
} as Parameters<typeof comboRepo.createCombo>[0]);
assert.equal(typeof combo.id, "string", "combo fixture must return an id");
return combo.id as string;
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("#12326: deleting a combo removes its LKGP pins", async () => {
const doomedId = await createCombo("doomed-combo");
await createCombo("survivor-combo");
await lkgpDb.setLKGP("doomed-combo", "model-x", "berry", "conn-1");
await lkgpDb.setLKGP("doomed-combo", "model-y", "berry", "conn-2");
await lkgpDb.setLKGP("survivor-combo", "model-x", "berry", "conn-3");
assert.equal(await comboRepo.deleteCombo(doomedId), true);
assert.equal(await lkgpDb.getLKGP("doomed-combo", "model-x"), null);
assert.equal(await lkgpDb.getLKGP("doomed-combo", "model-y"), null);
assert.deepEqual(await lkgpDb.getLKGP("survivor-combo", "model-x"), {
provider: "berry",
connectionId: "conn-3",
});
});
test("#12326: deleting a combo invalidates warmed LKGP read-cache entries", async () => {
const doomedId = await createCombo("cached-combo");
await lkgpDb.setLKGP("cached-combo", "model-x", "berry", "conn-1");
assert.deepEqual(await readCache.getCachedLKGP("cached-combo", "model-x"), {
provider: "berry",
connectionId: "conn-1",
});
assert.equal(await comboRepo.deleteCombo(doomedId), true);
assert.equal(
await readCache.getCachedLKGP("cached-combo", "model-x"),
null,
"deleted combos' LKGP pins must not survive in the read cache"
);
});
test("#12326: a combo whose name prefixes another keeps the sibling's pins", async () => {
const doomedId = await createCombo("prod");
await createCombo("prod-canary");
await lkgpDb.setLKGP("prod", "model-x", "berry", "conn-1");
await lkgpDb.setLKGP("prod-canary", "model-x", "berry", "conn-2");
assert.equal(await comboRepo.deleteCombo(doomedId), true);
assert.equal(await lkgpDb.getLKGP("prod", "model-x"), null);
assert.deepEqual(
await lkgpDb.getLKGP("prod-canary", "model-x"),
{ provider: "berry", connectionId: "conn-2" },
"the ':' delimiter must keep a prefix-sharing sibling's pins intact"
);
});
test("#12326: LIKE wildcards in a combo name do not widen the cleanup", async () => {
const doomedId = await createCombo("temp_a");
await createCombo("tempXa");
await lkgpDb.setLKGP("temp_a", "model-x", "berry", "conn-1");
await lkgpDb.setLKGP("tempXa", "model-x", "berry", "conn-2");
assert.equal(await comboRepo.deleteCombo(doomedId), true);
assert.equal(await lkgpDb.getLKGP("temp_a", "model-x"), null);
assert.deepEqual(
await lkgpDb.getLKGP("tempXa", "model-x"),
{ provider: "berry", connectionId: "conn-2" },
"'_' must be escaped so it cannot match an arbitrary character"
);
});
test("#12326: deleting an unknown combo id leaves LKGP state untouched", async () => {
await createCombo("untouched-combo");
await lkgpDb.setLKGP("untouched-combo", "model-x", "berry", "conn-1");
assert.equal(await comboRepo.deleteCombo("00000000-0000-0000-0000-000000000000"), false);
assert.deepEqual(await lkgpDb.getLKGP("untouched-combo", "model-x"), {
provider: "berry",
connectionId: "conn-1",
});
});
test("#12326: deleting a combo without pins succeeds", async () => {
const doomedId = await createCombo("no-pins-combo");
assert.equal(await comboRepo.deleteCombo(doomedId), true);
assert.equal(await lkgpDb.getLKGP("no-pins-combo", "model-x"), null);
});

View File

@@ -7,6 +7,7 @@ import {
} from "../../open-sse/config/providerPluginManifest.ts";
import type { RegistryEntry } from "../../open-sse/config/providers/shared.ts";
import { USAGE_FETCHER_PROVIDERS } from "../../open-sse/services/usage/fetcherProviders.ts";
import { USAGE_SUPPORTED_PROVIDERS } from "../../open-sse/services/usage/supportedProviders.ts";
const registryFixture: Record<string, RegistryEntry> = {
openai: {
@@ -182,3 +183,81 @@ test("usage-fetch matches the fetcher list by alias too (#11722)", () => {
assert.ok(entry);
assert.ok(entry.capabilities.includes("usage-fetch"));
});
test("manifest advertises usage-supported for providers whose usage API is accepted (#10078)", () => {
// claude is in USAGE_SUPPORTED_PROVIDERS, openai is not — assert against the real
// list so the test cannot drift silently if the list moves.
const claude = getProviderPluginManifestEntryFromRegistry(registryFixture, "claude");
assert.ok(claude);
assert.ok(
(USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("claude"),
"fixture guard: claude must stay in USAGE_SUPPORTED_PROVIDERS for this test to mean anything"
);
assert.ok(
claude.capabilities.includes("usage-supported"),
"claude is in USAGE_SUPPORTED_PROVIDERS, so the manifest must advertise usage-supported"
);
});
test("manifest omits usage-supported for providers outside USAGE_SUPPORTED_PROVIDERS (#10078)", () => {
for (const providerId of ["openai", "anthropic", "claude-web"]) {
const entry = getProviderPluginManifestEntryFromRegistry(registryFixture, providerId);
assert.ok(entry, `fixture guard: ${providerId} must resolve`);
assert.equal(
(USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes(entry.id),
false,
`fixture guard: ${entry.id} must stay out of USAGE_SUPPORTED_PROVIDERS`
);
assert.equal(
entry.capabilities.includes("usage-supported"),
false,
`${entry.id} is not in USAGE_SUPPORTED_PROVIDERS, so usage-supported must not be advertised`
);
}
});
test("usage-supported matches only on id, not alias (#10078)", () => {
// USAGE_SUPPORTED_PROVIDERS is checked with a plain .includes(providerId) — no alias
// resolution (providerQuotaVisibility.ts:12, providerLimits.ts:178). The manifest must
// keep the same rule: an alias-only hit must NOT emit the tag.
const aliasOnlyFixture: Record<string, RegistryEntry> = {
"some-provider": {
id: "some-provider",
alias: "claude",
format: "openai",
executor: "default",
baseUrl: "https://some.example/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "m1", name: "M1" }],
},
};
assert.equal(
(USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("some-provider"),
false,
"fixture guard: the id must NOT be in the list"
);
assert.ok(
(USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("claude"),
"fixture guard: the alias must be in the list, otherwise this test proves nothing"
);
const entry = getProviderPluginManifestEntryFromRegistry(aliasOnlyFixture, "some-provider");
assert.ok(entry);
assert.equal(
entry.capabilities.includes("usage-supported"),
false,
"usage-supported is id-only — an alias hit must not advertise it"
);
// Sanity: the same entry MUST still carry usage-fetch via its alias, proving the
// two tags deliberately diverge on alias handling.
assert.ok(
(USAGE_FETCHER_PROVIDERS as readonly string[]).includes("claude"),
"fixture guard: claude must also be in USAGE_FETCHER_PROVIDERS for the divergence check"
);
assert.ok(entry.capabilities.includes("usage-fetch"));
});

View File

@@ -34,23 +34,6 @@ test("passthrough no fake: tool_only contentLength==0 -> no estimate (tool_calls
import { createSSEStream } from "../../open-sse/utils/stream.ts";
function collectSSE(stream: TransformStream<Uint8Array, Uint8Array>) {
return async (writable: WritableStream<Uint8Array>, readable: ReadableStream<Uint8Array>) => {
const chunks: string[] = [];
const decoder = new TextDecoder();
const reader = readable.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(decoder.decode(value, { stream: true }));
}
} finally {
reader.releaseLock();
}
return chunks.join("");
};
}
function parseSSEUsage(sseText: string): unknown[] {
return sseText
@@ -107,7 +90,7 @@ test("passthrough SSE: finish stop without usage + include_usage:true -> emits u
assert.ok(typeof usage.completion_tokens === "number" && usage.completion_tokens > 0);
});
test("passthrough SSE: trailing choices:[] valid after estimated finish -> trailing is dropped (estimated wins)", async () => {
test("passthrough SSE: real trailing choices:[] usage is forwarded; no estimate is emitted (real wins)", async () => {
const body = { model: "m", messages: [{ role: "user", content: "hi" }], stream: true, stream_options: { include_usage: true } };
const stream = createSSEStream({
mode: "passthrough" as const,
@@ -129,17 +112,23 @@ test("passthrough SSE: trailing choices:[] valid after estimated finish -> trail
})();
const enc = new TextEncoder();
await writer.write(enc.encode(`data: ${JSON.stringify({ id: "chatcmpl-1", object: "chat.completion.chunk", choices: [{ index: 0, delta: { content: "hello world" }, finish_reason: null }] })}\n\n`));
// finish without usage -> should estimate (injectedUsage=false at that point)
// finish without usage -> passes through untouched (estimate only happens at flush, and only if no usage ever arrives)
await writer.write(enc.encode(`data: ${JSON.stringify({ id: "chatcmpl-1", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`));
// trailing choices:[] with valid usage 50ms after -> inside empty-choices block hasValid(emptyChoicesUsage)&&!injectedUsage is now false, so chunk is dropped (warn path)
// trailing choices:[] with valid usage -> forwarded verbatim (marks passthroughForwardedUsage, so flush skips the estimate)
await writer.write(enc.encode(`data: ${JSON.stringify({ id: "chatcmpl-1", object: "chat.completion.chunk", choices: [], usage: { prompt_tokens: 8, completion_tokens: 6, total_tokens: 14 } })}\n\n`));
await writer.write(enc.encode("data: [DONE]\n\n"));
await writer.close();
const text = await readAll;
const parsed = parseSSEUsage(text);
const withUsage = parsed.filter((p: unknown) => (p as Record<string, unknown>).usage);
// With the guard, the trailing valid is dropped (estimated was already sent on finish). Without guard we would see 2 (double). We assert drop.
// If upstream ever sends real include_usage trailing, this documents the v1 tradeoff: estimated wins, valid is dropped.
assert.equal(withUsage.length, 1, `expected 1 usage (estimated, trailing dropped), got ${withUsage.length} — usages: ${JSON.stringify(withUsage.map((p) => (p as Record<string, unknown>).usage))}`);
assert.equal((withUsage[0] as Record<string, unknown> & { usage: Record<string, unknown> }).usage.estimated, true);
// v2 contract (#12151 follow-up): the upstream's REAL trailing usage block is forwarded
// and wins; the estimate exists only for upstreams that never report usage (emitted at
// flush). Exactly one usage block ever reaches the client — never two, never estimated
// when a real one arrived (the v1 "estimated wins" tradeoff was a billing regression).
assert.equal(withUsage.length, 1, `expected 1 usage (the real trailing block), got ${withUsage.length} — usages: ${JSON.stringify(withUsage.map((p) => (p as Record<string, unknown>).usage))}`);
const forwarded = (withUsage[0] as Record<string, unknown> & { usage: Record<string, unknown> }).usage;
assert.equal(forwarded.estimated, undefined);
assert.equal(forwarded.prompt_tokens, 8);
assert.equal(forwarded.completion_tokens, 6);
assert.equal(forwarded.total_tokens, 14);
});