diff --git a/open-sse/services/combo/dispatchPrelude.ts b/open-sse/services/combo/dispatchPrelude.ts index 833b8f86c4..65dd76e175 100644 --- a/open-sse/services/combo/dispatchPrelude.ts +++ b/open-sse/services/combo/dispatchPrelude.ts @@ -619,6 +619,7 @@ export async function tryRuntimeUnitDispatch(args: { nesting: nestingContext, baseOptions: buildBaseOptions(args), runCombo: args.runCombo, + hiddenModelsByProvider: args.hiddenModelsByProvider, }); recordRuntimeUnitStickySuccess({ strategy, diff --git a/open-sse/services/combo/runtimeUnitCapacity.ts b/open-sse/services/combo/runtimeUnitCapacity.ts index 4d0265b7a8..6af5430043 100644 --- a/open-sse/services/combo/runtimeUnitCapacity.ts +++ b/open-sse/services/combo/runtimeUnitCapacity.ts @@ -9,7 +9,12 @@ import { isAccountSemaphoreFull } from "../accountSemaphore.ts"; import { resolveComboTargets } from "./comboStructure.ts"; import { lookupPositiveCap } from "./concurrencyCaps.ts"; -import type { ComboCollectionLike, ComboLike, ResolvedComboUnit } from "./types.ts"; +import type { + ComboCollectionLike, + ComboLike, + HiddenModelsByProvider, + ResolvedComboUnit, +} from "./types.ts"; type CapLookup = (connectionId: string) => Promise; @@ -45,7 +50,12 @@ async function isConnectionAtConcurrencyCap( export async function isRuntimeUnitAtConcurrencyCap( unit: ResolvedComboUnit, allCombos: ComboCollectionLike, - lookupCap: CapLookup = lookupPositiveCap + lookupCap: CapLookup = lookupPositiveCap, + // Threaded from the caller so the hidden-model snapshot resolved once per + // request is reused. Without it resolveComboTargets falls back to its default + // getHiddenModelsByProvider(), i.e. a fresh full key_value read per nested + // combo-ref unit on EVERY request (#8878 threaded the other call sites). + hiddenModelsByProvider?: HiddenModelsByProvider ): Promise { if (unit.kind === "model") { if (!unit.connectionId || !unit.provider) return false; @@ -55,7 +65,7 @@ export async function isRuntimeUnitAtConcurrencyCap( const childCombo = findComboByName(allCombos, unit.comboName); if (!childCombo) return false; - const targets = resolveComboTargets(childCombo, allCombos, 1); + const targets = resolveComboTargets(childCombo, allCombos, 1, hiddenModelsByProvider); const byConnection = new Map(); for (const target of targets) { if (!target.connectionId || !target.provider) continue; diff --git a/open-sse/services/combo/runtimeUnits.ts b/open-sse/services/combo/runtimeUnits.ts index 28128d11f3..90ad1c2cac 100644 --- a/open-sse/services/combo/runtimeUnits.ts +++ b/open-sse/services/combo/runtimeUnits.ts @@ -18,6 +18,7 @@ import type { ComboNestingContext, HandleComboChatOptions, HandleSingleModel, + HiddenModelsByProvider, IsModelAvailable, ResolvedComboRefTarget, ResolvedComboUnit, @@ -186,6 +187,7 @@ export async function executeRuntimeUnitCombo(args: { nesting: ComboNestingContext; baseOptions: HandleComboChatOptions; runCombo: RuntimeUnitRunner; + hiddenModelsByProvider?: HiddenModelsByProvider; }): Promise { const maxRetries = Number(args.config.maxRetries ?? 1); const retryDelayMs = resolveDelayMs(args.config.retryDelayMs, 2000); @@ -197,7 +199,14 @@ export async function executeRuntimeUnitCombo(args: { let fallbackCount = 0; for (const unit of orderedUnits) { - if (await isRuntimeUnitAtConcurrencyCap(unit, args.allCombos)) { + if ( + await isRuntimeUnitAtConcurrencyCap( + unit, + args.allCombos, + undefined, + args.hiddenModelsByProvider + ) + ) { args.log.info( "COMBO", `Skipping ${unit.kind} ${unitDisplayName(unit)} — concurrency cap reached` diff --git a/open-sse/services/usage/firecrawl.ts b/open-sse/services/usage/firecrawl.ts index 09ea1f5211..793df7e673 100644 --- a/open-sse/services/usage/firecrawl.ts +++ b/open-sse/services/usage/firecrawl.ts @@ -5,7 +5,11 @@ * credits into the standard `{ plan, quotas }` response. */ -import { fetchFirecrawlQuota, getFirecrawlBaseUrl, type FirecrawlQuota } from "../firecrawlQuotaFetcher.ts"; +import { + fetchFirecrawlQuota, + getFirecrawlBaseUrl, + type FirecrawlQuota, +} from "../firecrawlQuotaFetcher.ts"; import { createQuotaFromUsage, parseResetTime } from "./quota.ts"; function createFirecrawlPlanQuota(q: FirecrawlQuota) { @@ -29,7 +33,11 @@ function createFirecrawlPlanQuota(q: FirecrawlQuota) { }; } -export async function getFirecrawlUsage(connectionId: string, apiKey?: string, connection?: Record) { +export async function getFirecrawlUsage( + connectionId: string, + apiKey?: string, + connection?: Record +) { if (!connectionId) { return { message: "Firecrawl: connection id unavailable." }; } @@ -44,7 +52,12 @@ export async function getFirecrawlUsage(connectionId: string, apiKey?: string, c } try { - const live = await fetchFirecrawlQuota(connectionId, connection); + // The explicit `apiKey` argument was silently dropped when #91bb6aa619 moved + // this to fetchFirecrawlQuota(connectionId, connection): the fetcher reads the + // key off the connection record, so a caller that passes the key directly — + // without a connection carrying it — always got "API key not available". + const resolvedConnection = apiKey ? { ...(connection || {}), apiKey } : connection; + const live = await fetchFirecrawlQuota(connectionId, resolvedConnection); if (!live) { return { message: "Firecrawl API key not available or credit usage unavailable." }; } diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index b6fdb29a4a..ac1aaa1680 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -111,6 +111,14 @@ export const OAUTH_TEST_CONFIG = { // Validate using token presence/expiry as a lightweight auth check. checkExpiry: true, }, + raycast: { + // #8895 — Raycast Pro is an `import_token` provider: the token is imported + // from the local Raycast install, `refreshToken` is always null and the + // stored `expiresIn` defaults to 30 days. There is nothing to refresh, so + // the test is the expiry check on the imported token; without an entry here + // Test Connection persists testStatus="error" on a healthy account (#8408). + checkExpiry: true, + }, cline: CLINE_OAUTH_TEST_CONFIG, // ClinePass reuses the same WorkOS OAuth flow and token lifecycle as Cline. clinepass: CLINE_OAUTH_TEST_CONFIG, diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 0aed3399c9..e9727c9624 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5475,13 +5475,13 @@ "newApiUserIdLabel": "ID de Usuário New-API", "newApiUserIdPlaceholder": "ex.: 12345", "newApiUserIdHint": "Valor do cabeçalho New-Api-User do AgentRouter, usado junto com a chave de API do console para consultar o saldo de cota.", - "newApiAggregatorToggleLabel": "Gateway Agregador", - "newApiAggregatorToggleHint": "Ativar detecção de saldo para nós agregadores New-API / One-API / Sub2API. O painel mostrará o badge de saldo e o roteamento de pré-voo de cota ignorará contas esgotadas.", - "newApiAggregatorConsoleApiKeyHint": "Token de Acesso do Sistema para o endpoint /api/user/self do agregador. Não é a chave de API de roteamento.", - "newApiAggregatorUserIdHint": "Valor do cabeçalho New-Api-User usado para consultar o saldo de cota do usuário do agregador.", - "newApiAggregatorQuotaPerUnitLabel": "Cota por Unidade", - "newApiAggregatorQuotaPerUnitHint": "Unidades de crédito New-API por $1 (padrão: 500000). Substitua se seu agregador usar uma taxa diferente.", - "featureFlagNewApiAggregatorBalanceDescription": "Ativar detecção de saldo para nós compatíveis de agregadores New-API / One-API / Sub2API", + "newApiAggregatorToggleLabel": "Gateway agregador", + "newApiAggregatorToggleHint": "Ativa a detecção de saldo para nós agregadores New-API / One-API / Sub2API. O painel passa a mostrar o selo de saldo e o roteamento com quota-preflight pula contas esgotadas.", + "newApiAggregatorConsoleApiKeyHint": "System Access Token para o endpoint /api/user/self do agregador. Não é a chave de API de roteamento.", + "newApiAggregatorUserIdHint": "Valor do cabeçalho New-Api-User usado para buscar o saldo de quota do usuário do agregador.", + "newApiAggregatorQuotaPerUnitLabel": "Quota por unidade", + "newApiAggregatorQuotaPerUnitHint": "Unidades de crédito New-API por US$ 1 (padrão: 500000). Substitua se o seu agregador usar outra taxa.", + "featureFlagNewApiAggregatorBalanceDescription": "Ativa a detecção de saldo para nós compatíveis New-API / One-API / Sub2API", "cpaModeDisabledTitle": "Habilitar backend CLIProxyAPI para emulação OAuth mais profunda do Claude Code", "cpaModeEnabledTitle": "Usando CLIProxyAPI para uma emulação mais profunda do Claude Code (uTLS, multi-conta, perfis de dispositivo)", "customUserAgentHint": "Override opcional enviado upstream como cabeçalho User-Agent desta conexão.", @@ -5597,7 +5597,7 @@ "tagGroupPlaceholder": "ex.: personal, work, team-a", "testModel": "Test Model", "testingModel": "Testing Model", - "modelTestQuotaTooltip": "Cota esgotada — reinicia amanhã ou precisa de recarga", + "modelTestQuotaTooltip": "Quota esgotada — reseta amanhã ou precisa de recarga", "toggleOffShort": "OFF", "toggleOnShort": "ON", "tokenExpiredBadge": "Expirado", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index f6333012d9..828d2ae2ba 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -6020,7 +6020,15 @@ "ccAliasAddModelPlaceholder": "Id mô hình (ví dụ: gpt-4o)", "ccAliasAddModelButton": "Thêm ghi đè", "ccAliasLoadError": "Không tải được cài đặt bí danh khám phá: {error}", - "ccAliasSaveError": "Không lưu được cài đặt bí danh khám phá: {error}" + "ccAliasSaveError": "Không lưu được cài đặt bí danh khám phá: {error}", + "newApiAggregatorToggleLabel": "Cổng tổng hợp", + "newApiAggregatorToggleHint": "Bật phát hiện số dư cho các node tổng hợp New-API / One-API / Sub2API. Bảng điều khiển sẽ hiển thị huy hiệu số dư và định tuyến quota-preflight sẽ bỏ qua các tài khoản đã cạn.", + "newApiAggregatorConsoleApiKeyHint": "System Access Token cho endpoint /api/user/self của bộ tổng hợp. Không phải khóa API định tuyến.", + "newApiAggregatorUserIdHint": "Giá trị header New-Api-User dùng để lấy số dư quota của người dùng bộ tổng hợp.", + "newApiAggregatorQuotaPerUnitLabel": "Quota mỗi đơn vị", + "newApiAggregatorQuotaPerUnitHint": "Số đơn vị tín dụng New-API cho mỗi 1 USD (mặc định: 500000). Ghi đè nếu bộ tổng hợp của bạn dùng tỷ lệ khác.", + "featureFlagNewApiAggregatorBalanceDescription": "Bật phát hiện số dư cho các node tương thích New-API / One-API / Sub2API", + "modelTestQuotaTooltip": "Đã hết quota — sẽ đặt lại vào ngày mai hoặc cần nạp thêm" }, "settings": { "title": "Cài đặt", diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index f9aee266a0..02df89e7c9 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -531,6 +531,10 @@ export const PROVIDERS = { KIRO: "kiro", AMAZON_Q: "amazon-q", CURSOR: "cursor", + // #8895 — registered in src/lib/oauth/providers/index.ts but missing here, so + // every consumer reading PROVIDERS (onboarding wizard, test-connection routing) + // did not know Raycast Pro exists as an OAuth provider. + RAYCAST: "raycast", KILOCODE: "kilocode", CLINE: "cline", CLINEPASS: "clinepass", diff --git a/tests/unit/chat-combo-live-test.test.ts b/tests/unit/chat-combo-live-test.test.ts index da01178824..3961157f0f 100644 --- a/tests/unit/chat-combo-live-test.test.ts +++ b/tests/unit/chat-combo-live-test.test.ts @@ -250,7 +250,13 @@ test("chat completions route emits early keepalive while waiting for stream read await seedHealthyConnection(); globalThis.fetch = async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); + // Must exceed resolveKeepaliveThreshold()'s DEFAULT_THRESHOLD_MS (2000ms) for + // openai/* — otherwise withEarlyStreamKeepalive takes the FAST path, forwards + // the handler response as-is and no keepalive frame is ever emitted. The old + // 100ms only worked while unrelated handler latency happened to push the + // total past the threshold, which made this assertion incidental rather than + // deterministic; it stopped holding once the handler got faster. + await new Promise((resolve) => setTimeout(resolve, 2_400)); return new Response( [ `data: ${JSON.stringify({ diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index c8a8f6d3d4..ff2cf36a66 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -30,13 +30,13 @@ const { isControlPlaneProxyDirectFallbackEnabled, } = await import("../../src/shared/utils/featureFlags.ts"); -const EXPECTED_FEATURE_FLAG_COUNT = 45; +const EXPECTED_FEATURE_FLAG_COUNT = 46; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry // ────────────────────────────────────────────────────── describe("featureFlagDefinitions", () => { - it("has exactly 45 flag definitions", () => { + it("has exactly 46 flag definitions", () => { assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, EXPECTED_FEATURE_FLAG_COUNT); }); @@ -332,7 +332,7 @@ describe("resolveFeatureFlag", () => { }); describe("resolveAllFeatureFlags", () => { - it("returns all 45 flags", () => { + it("returns all 46 flags", () => { const all = resolveAllFeatureFlags(); assert.strictEqual(all.length, EXPECTED_FEATURE_FLAG_COUNT); }); diff --git a/tests/unit/login-bootstrap-route.test.ts b/tests/unit/login-bootstrap-route.test.ts index 3764c0f84f..aff64ba9f2 100644 --- a/tests/unit/login-bootstrap-route.test.ts +++ b/tests/unit/login-bootstrap-route.test.ts @@ -46,6 +46,8 @@ test("public login bootstrap route exposes the metadata the login page consumes" assert.equal(response.status, 200); assert.deepEqual(body, { + // #9491 added `authenticated` so /login can redirect an active session. + authenticated: false, requireLogin: true, hasPassword: false, setupComplete: true, @@ -68,6 +70,8 @@ test("public login bootstrap route reports env-provided bootstrap password metad assert.equal(response.status, 200); assert.deepEqual(body, { + // #9491 added `authenticated` so /login can redirect an active session. + authenticated: false, requireLogin: true, hasPassword: true, setupComplete: true, @@ -89,6 +93,8 @@ test("public login bootstrap route reports stored password metadata and disabled assert.equal(response.status, 200); assert.deepEqual(body, { + // #9491 added `authenticated` so /login can redirect an active session. + authenticated: false, requireLogin: false, hasPassword: true, setupComplete: true, diff --git a/tests/unit/mcp-tool-count-dedup-6854.test.ts b/tests/unit/mcp-tool-count-dedup-6854.test.ts index c4cbbabe1f..aaa4d52477 100644 --- a/tests/unit/mcp-tool-count-dedup-6854.test.ts +++ b/tests/unit/mcp-tool-count-dedup-6854.test.ts @@ -6,7 +6,7 @@ import assert from "node:assert/strict"; // (omniroute_agent_skills_list/get/coverage) are intentionally defined in BOTH // MCP_TOOLS (open-sse/mcp-server/schemas/tools.ts) and agentSkillTools // (open-sse/mcp-server/tools/agentSkillTools.ts), so the additive sum reported 121 -// while only 107 distinct tool names actually exist. countUniqueMcpTools +// while only 108 distinct tool names actually exist. countUniqueMcpTools // (open-sse/mcp-server/toolCount.ts) fixes this by unioning tool names from every // registered collection into a Set, so each user-visible tool is counted once. @@ -60,7 +60,7 @@ test("#6854: countUniqueMcpTools de-duplicates tools registered in multiple coll }; const total = countUniqueMcpTools(collections); - assert.equal(total, 107, "the published MCP inventory must match the registered tool set"); + assert.equal(total, 108, "the published MCP inventory must match the registered tool set"); // Independently compute the "true" unique count by unioning every collection's // tool names into a Set — this must equal countUniqueMcpTools's own result AND diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 49432b2ab7..ef1ed581eb 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -40,6 +40,7 @@ const { OAUTH_TIMEOUT, PROVIDERS: OAUTH_PROVIDER_IDS, QODER_CONFIG, + RAYCAST_CONFIG, TRAE_CONFIG, WINDSURF_CONFIG, XAI_OAUTH_CONFIG, @@ -63,6 +64,7 @@ const EXPECTED_PROVIDER_KEYS = [ "amazon-q", "cursor", "trae", + "raycast", "kilocode", "cline", "clinepass", @@ -100,6 +102,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { clinepass: CLINE_CONFIG, // reuses the Cline WorkOS flow (clinepass: cline in providers/index.ts) windsurf: WINDSURF_CONFIG, "devin-cli": WINDSURF_CONFIG, + raycast: RAYCAST_CONFIG, trae: TRAE_CONFIG, "grok-cli": GROK_BUILD_OAUTH_CONFIG, "xai-oauth": XAI_OAUTH_CONFIG, diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index 32332fc7da..06def06549 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -1,7 +1,7 @@ // Characterization of the providers.ts catalog split (god-file decomposition): the host became a // barrel that re-exports 10 data catalogs now living under constants/providers/*, and APIKEY is // merged from 6 semantic family files (apikey/.ts). Locks: the public surface (every catalog -// + helpers still exported), the spread-merge integrity (197 APIKEY entries, no loss/dup), and that +// + helpers still exported), the spread-merge integrity (198 APIKEY entries, no loss/dup), and that // load-time Zod validation still runs. Pure-data move → behavior must be identical. // Count was 171 before obsolete provider removals (PR #6675: glhf/kluster/cablyai/inclusionai etc., // 171->167) plus #6126 (ClinePass dual-auth): the API-key-only APIKEY_PROVIDERS_GATEWAYS entry was @@ -17,7 +17,7 @@ // sarvam+plamo in regional) to 193, then #8170 (inception/typhoon — inception in frontier-labs, // typhoon in regional) to 195, then Firecrawl dual search+fetch under SEARCH_PROVIDERS.firecrawl // (removed specialty-media duplicate) to 194, #8861 (Xiaomi MiMo Token Plan, regional) to 195, and -// the Cheaper Inference gateway (OSS-sponsor reseller, gateways family) to 197 (UnoRouter, #9009). +// the Cheaper Inference gateway (OSS-sponsor reseller, gateways family) to 198 (UnoRouter #9009, Raycast Pro #8895). import { test } from "node:test"; import assert from "node:assert/strict"; @@ -46,12 +46,12 @@ test("barrel still exports every catalog + key helpers", () => { } }); -test("APIKEY_PROVIDERS merges the 6 family files into 197 entries (no loss / no dup)", async () => { +test("APIKEY_PROVIDERS merges the 6 family files into 198 entries (no loss / no dup)", async () => { const keys = Object.keys((P as Record).APIKEY_PROVIDERS); - assert.equal(keys.length, 197); - assert.equal(new Set(keys).size, 197, "duplicate keys after spread-merge"); + assert.equal(keys.length, 198); + assert.equal(new Set(keys).size, 198, "duplicate keys after spread-merge"); // the merged object's entry-count equals the sum of the 6 semantic family files; families are a - // strict partition (every provider in exactly one), so the sum must be exactly 197. + // strict partition (every provider in exactly one), so the sum must be exactly 198. const families: [string, string][] = [ ["gateways", "APIKEY_PROVIDERS_GATEWAYS"], ["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"], @@ -71,7 +71,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 197 entries (no loss / no seen.add(k); } } - assert.equal(famTotal, 197, "families must partition all 197 providers"); + assert.equal(famTotal, 198, "families must partition all 198 providers"); }); test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {