mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-02 04:42:17 +03:00
Compare commits
4 Commits
dependabot
...
fix/12326-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a18a6eb5a | ||
|
|
1586476183 | ||
|
|
c9f9b6274e | ||
|
|
9d5ab8ccbf |
1
changelog.d/features/12214-usage-supported-capability.md
Normal file
1
changelog.d/features/12214-usage-supported-capability.md
Normal 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
|
||||
1
changelog.d/fixes/12330-combo-delete-lkgp-cleanup.md
Normal file
1
changelog.d/fixes/12330-combo-delete-lkgp-cleanup.md
Normal 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))
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
79
open-sse/services/usage/supportedProviders.ts
Normal file
79
open-sse/services/usage/supportedProviders.ts
Normal 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",
|
||||
];
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -840,6 +840,7 @@ export {
|
||||
setLKGP,
|
||||
clearAllLKGP,
|
||||
clearLKGP,
|
||||
deleteLKGPByComboName,
|
||||
deleteLKGPByConnectionIds,
|
||||
} from "./settings/lkgp";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
152
tests/unit/combo-delete-lkgp-cleanup-12326.test.ts
Normal file
152
tests/unit/combo-delete-lkgp-cleanup-12326.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user