diff --git a/frontend/src/utils/__tests__/ccswitchImport.spec.ts b/frontend/src/utils/__tests__/ccswitchImport.spec.ts index 6f23c1aa3b..fd178f76fd 100644 --- a/frontend/src/utils/__tests__/ccswitchImport.spec.ts +++ b/frontend/src/utils/__tests__/ccswitchImport.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + CC_SWITCH_USAGE_SCRIPT, GROK_CC_SWITCH_MODEL, OPENAI_CC_SWITCH_CODEX_MODEL, buildCcSwitchImportDeeplink @@ -100,3 +101,38 @@ describe('ccswitchImport utils', () => { expect(params.has('model')).toBe(false) }) }) + +describe('CC Switch usage script', () => { + // Mirrors CC Switch: substitute the template vars as text, evaluate, read request.url. + function usageUrlFor(baseUrl: string): string { + const script = CC_SWITCH_USAGE_SCRIPT.split('{{baseUrl}}').join(baseUrl).split('{{apiKey}}').join('sk-test') + // eslint-disable-next-line no-new-func + const config = new Function(`return ${script}`)() as { request: { url: string } } + return config.request.url + } + + it.each([ + 'https://api.example.com', + 'https://api.example.com/', + 'https://api.example.com/v1', + 'https://api.example.com/v1/' + ])('queries exactly one /v1/usage for base URL %s', (baseUrl) => { + expect(usageUrlFor(baseUrl)).toBe('https://api.example.com/v1/usage') + }) + + it('works against the endpoint every platform import stores', () => { + for (const platform of ['anthropic', 'openai', 'grok', 'gemini'] as GroupPlatform[]) { + const endpoint = paramsFromDeeplink( + buildCcSwitchImportDeeplink({ + baseUrl: 'https://api.example.com', + platform, + clientType: platform === 'gemini' ? 'gemini' : 'claude', + providerName: 'Sub2API', + apiKey: 'sk-test', + usageScript: CC_SWITCH_USAGE_SCRIPT + }) + ).get('endpoint') as string + expect(usageUrlFor(endpoint)).toBe('https://api.example.com/v1/usage') + } + }) +}) diff --git a/frontend/src/utils/ccswitchImport.ts b/frontend/src/utils/ccswitchImport.ts index f9ca257349..1aee39d159 100644 --- a/frontend/src/utils/ccswitchImport.ts +++ b/frontend/src/utils/ccswitchImport.ts @@ -20,6 +20,31 @@ export interface CcSwitchImportDeeplinkInput { usageScript: string } +/** + * Balance query CC Switch runs against the imported provider. CC Switch fills + * `{{baseUrl}}` with the provider's base URL as stored — Codex and Grok imports + * carry a trailing `/v1` (see `withV1Endpoint`), Claude ones do not, and users + * may edit it either way afterwards — then evaluates the script, so the URL + * strips an existing `/v1` instead of blindly appending one (`/v1/v1/usage` + * is a 404 and CC Switch shows "query failed"). + */ +export const CC_SWITCH_USAGE_SCRIPT = `({ + request: { + url: "{{baseUrl}}".replace(/\\/+$/, "").replace(/\\/v1$/, "") + "/v1/usage", + method: "GET", + headers: { "Authorization": "Bearer {{apiKey}}" } + }, + extractor: function(response) { + const remaining = response?.remaining ?? response?.quota?.remaining ?? response?.balance; + const unit = response?.unit ?? response?.quota?.unit ?? "USD"; + return { + isValid: response?.is_active ?? response?.isValid ?? true, + remaining, + unit + }; + } + })` + function withV1Endpoint(baseUrl: string): string { const normalizedBaseUrl = baseUrl.replace(/\/+$/, '') return normalizedBaseUrl.endsWith('/v1') ? normalizedBaseUrl : `${normalizedBaseUrl}/v1` diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue index ed33a4c683..3011b52a11 100644 --- a/frontend/src/views/user/KeysView.vue +++ b/frontend/src/views/user/KeysView.vue @@ -1231,6 +1231,7 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue' import { platformBadgeLightClass } from '@/utils/platformColors' import { KEY_GROUP_PROVIDERS, KEY_GROUP_PROVIDER_ICONS, getKeyGroupProvider, type KeyGroupProvider } from '@/utils/keyGroupProviders' import { + CC_SWITCH_USAGE_SCRIPT, buildCcSwitchImportDeeplink, type CcSwitchClientType } from '@/utils/ccswitchImport' @@ -2025,22 +2026,7 @@ const executeCcsImport = (row: ApiKey, clientType: CcSwitchClientType) => { const baseUrl = publicSettings.value?.api_base_url || window.location.origin const platform = row.group?.platform || 'anthropic' - const usageScript = `({ - request: { - url: "{{baseUrl}}/v1/usage", - method: "GET", - headers: { "Authorization": "Bearer {{apiKey}}" } - }, - extractor: function(response) { - const remaining = response?.remaining ?? response?.quota?.remaining ?? response?.balance; - const unit = response?.unit ?? response?.quota?.unit ?? "USD"; - return { - isValid: response?.is_active ?? response?.isValid ?? true, - remaining, - unit - }; - } - })` + const usageScript = CC_SWITCH_USAGE_SCRIPT const providerName = (publicSettings.value?.site_name || 'sub2api').trim() || 'sub2api' const deeplink = buildCcSwitchImportDeeplink({ baseUrl,