Files
OmniRoute/src/shared/utils/providerCredentialRequirement.ts
Diego Rodrigues de Sa e Souza f48e25194f fix(providers): retire Qwen Web pending provenance review (#11713)
Rebased onto the current release/v3.8.51 tip as part of a combined provider-retirement/provenance merge batch (Designer Web, Felo Web, Runtime, GPL-derived removal all landed together already). Conflicts resolved:

- `src/shared/constants/providerRetirement.ts`: add/add conflict — combined `felo-web`/`felo` (already-merged) with `qwen-web`/`qw` into one `RUNTIME_RETIRED_PROVIDER_IDS` set, kept both `assertRuntimeProviderAvailable`/`assertRuntimeModelProviderAvailable` helpers.
- `open-sse/config/providers/registry/minimax/web/index.ts`: modify/delete — kept deleted (file is hailuo-web's registry entry, already retired by #11691; this PR's own change to it was just a comment reword on a since-removed target).
- `open-sse/executors/index.ts`, `executorProxy.ts`, `virtualFactory.ts`, `autoStrategy.ts`, `model.ts`, `chat.ts`, `chatHelpers.ts`, `auth.ts`, `src/lib/db/providers.ts`, `reservedProviderPrefixes.ts`: combined the Designer + Runtime (Felo + Qwen) retirement guard calls at each shared chokepoint — compute-once-then-OR pattern, consistent with the prior Designer+Felo combination.
- `src/shared/constants/providers/web-cookie.ts`, `tests/snapshots/provider/translate-path.json`, `tests/snapshots/executors/executor-map.json`: both sides had inserted a different retired provider (qwen-web vs. already-retired raycast/hailuo-web) at the same dict position — resolved by dropping both. `executor-map.json`'s `keyCount` recomputed to 135 (matches actual merged `entries`).
- `tests/unit/chatcore-executor-proxy.test.ts`, `tests/unit/provider-node-reserved-prefix.test.ts`: split into independent Felo/Qwen test blocks (established pattern for coexisting retirement-mechanism tests); recomputed `RESERVED_PREFIX_COUNT` to 398 (Designer+Felo+Qwen tombstones on top of the post-#11691 REGISTRY, verified via direct module evaluation, not hand-derived).
- `config/quality/test-masking-allowlist.json`: additive merge of Qwen's `_deletedWithReplacement` entries alongside Designer's.
- `README.md` + all `docs/i18n/*/README.md` mirrors, `docs/getting-started/FREE-TIERS-GUIDE.md`, `docs/reference/FREE_TIERS.md`, `docs/diagrams/free-tier-budget.svg`, `docs/screenshots/free-tier-budget-card.svg`: recomputed the free-tier catalog counts (447 entries / 440 active / 7 discontinued) from the actual merged `freeModelCatalog.data.ts`, regenerated the budget-card SVG via its real generator (`scripts/research/gen-budget-card-svg.mjs`), and dropped the retired Qwen quick-start row / QWEN MODELS section from every i18n README (identical unlocalized block across all 34 locales).
- Also fixed a duplicate-import merge artifact in `src/lib/db/providers.ts` (`isRuntimeRetiredProviderId` imported twice) caught by `typecheck:core`, and rebaselined `file-size-baseline.json` for the combined retirement-guard growth (`virtualFactory.ts` +3, with justification).

Focused suite green (345/345 across executor-proxy, reserved-prefix, migration-167, qwen-web-retirement, virtual-auto-combo, web-cookie/session, executor-map-golden and siblings), plus `typecheck:core` and `check-file-size`/`check-changelog-integrity` clean. Thanks for the provenance-hold retirement work — appreciated.
2026-08-28 06:01:21 -03:00

119 lines
5.2 KiB
TypeScript

/**
* Single answer to "does this provider need a credential?".
*
* Two different questions had been collapsed into the word "keyless":
*
* 1. **Is the free access quantifiable in tokens?** That is what
* `FreeModelBudget.freeType === "keyless"` means — it sits next to `oauth`
* in the docs as "not token-quantifiable", which is why those rows never
* reach the headline. It says nothing about credentials.
* 2. **Can the user call it with nothing configured?** That is this module.
*
* Reading (1) as (2) is not academic: probing the endpoints on 2026-07-20 showed
* blackbox, friendliai, iflytek and sparkdesk answering 401, and
* muse-spark-web 403, with no credential — yet all are `freeType: "keyless"`.
* A UI section built on (1) would invite users to call providers that reject
* them. So anything user-facing about API keys must come from here.
*
* The answer is derived from the two registries that describe real behaviour —
* `NOAUTH_PROVIDERS` (does the connect form ask for a key?) and `RegistryEntry`
* (what does the executor actually send?) — so there is no new list to keep in
* sync: registering a provider the usual way is enough.
*/
import { NOAUTH_PROVIDERS } from "../constants/providers/noauth.ts";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
export type CredentialRequirement =
/** Never needs a credential — the connect form does not even ask for one. */
| "none"
/** Works anonymously; a credential is accepted and usually raises the limits. */
| "optional"
/** No API key to paste, but the user still signs in (OAuth/device flow). */
| "oauth"
/** Unusable without a credential (API key, cookie or session token). */
| "required";
/** True when the user can call the provider with nothing configured. */
export function worksWithoutCredential(req: CredentialRequirement): boolean {
return req === "none" || req === "optional";
}
export function getCredentialRequirement(providerId: string): CredentialRequirement {
const entry = REGISTRY[providerId];
// Checked before `noAuth`: a literal anonymous token means the executor can
// call upstream with no user credential (Kilo's `anonymous`, AI Horde's
// `0000000000`) *and* that a real key is still honoured — AI Horde trades one
// for higher queue priority. That is "optional", not "none", even though the
// connect form hides the field.
if (entry?.anonymousApiKey) return "optional";
const noAuth = (NOAUTH_PROVIDERS as Record<string, { noAuth?: boolean }>)[providerId];
if (noAuth?.noAuth === true) return "none";
if (!entry) return "required";
if (entry.authType === "none") return "none";
if (entry.authType === "optional") return "optional";
if (entry.authType === "oauth") return "oauth";
return "required";
}
/**
* Every provider usable with nothing configured, sorted for stable output.
* This — not `freeType === "keyless"` — is what a "no API key required" list
* must be built from.
*/
export function listNoCredentialProviders(): string[] {
const ids = new Set([...Object.keys(NOAUTH_PROVIDERS), ...Object.keys(REGISTRY)]);
return [...ids].filter((id) => worksWithoutCredential(getCredentialRequirement(id))).sort();
}
/**
* Providers whose catalog rows are `freeType: "keyless"` while routing still
* needs a credential.
*
* This is NOT a bug list — the two fields answer different questions (see the
* module header). It exists so the mismatch stays visible and measured: each of
* these is a provider whose free access is real but reached through a web/session
* path, and each was confirmed by probing the endpoint. Kept frozen so a genuine
* new mistake still trips the gate.
*/
export const NOT_TOKEN_QUANTIFIABLE_BUT_CREDENTIALED: readonly string[] = [
"agy", // OAuth sign-in; nothing to paste, but still an account
"blackbox", // probed 2026-07-20 -> 401 "No api key passed in"
"friendliai", // probed -> 401 "no authorization info provided"
"iflytek", // probed -> 401 Unauthorized
"liquid", // probed -> 404: endpoint moved; config needs a separate audit
"muse-spark-web", // probed -> 403; authHeader is a session cookie, not a key
"sparkdesk", // probed -> 401 Unauthorized
];
export interface KeylessConsistencyReport {
/** Catalog says keyless, routing needs a credential, and it is not recorded. */
unexpected: string[];
/** Recorded entries that now work without a credential — drop them. */
stale: string[];
}
/**
* Compare the catalog's `keyless` rows against real routing behaviour.
* Pure function: callers pass the catalog so tests and gates can reuse it.
*/
export function checkKeylessCatalogConsistency(
catalog: readonly { provider: string; freeType: string }[]
): KeylessConsistencyReport {
const labelledKeyless = [
...new Set(catalog.filter((m) => m.freeType === "keyless").map((m) => m.provider)),
].sort();
const credentialed = labelledKeyless.filter(
(id) => !worksWithoutCredential(getCredentialRequirement(id))
);
const recorded = new Set(NOT_TOKEN_QUANTIFIABLE_BUT_CREDENTIALED);
return {
unexpected: credentialed.filter((id) => !recorded.has(id)),
stale: [...recorded].filter((id) => !credentialed.includes(id)).sort(),
};
}