diff --git a/docs/internal-review/bundle-baseline.md b/docs/internal-review/bundle-baseline.md index 553a668f..8e53683b 100644 --- a/docs/internal-review/bundle-baseline.md +++ b/docs/internal-review/bundle-baseline.md @@ -20,7 +20,7 @@ bun run build | `index` | 396.29 kB | 121.04 kB | 多账号切换(AuthProvider vault + AppShell 账号菜单)进入 shell 入口;后续可再拆 account menu | | `index.css` | 430.02 kB | 86.34 kB | 入口样式表;含 114 条自托管字体 @font-face 分片声明(Inter / Noto Sans SC / JetBrains Mono),字体文件本身按 unicode-range 懒加载不计入此行 | | `ConfigPage` | 119.60 kB | 33.35 kB | 页面 chunk 低于预算 | -| `AuthFilesPage` | 216.81 kB | 58.90 kB | 身份多档案与出站策略交互加入后仍低于页面 gzip 预算 | +| `AuthFilesPage` | 234.90 kB | 64.36 kB | 与 dev 现状对齐(原记 58.90 kB 已落后约 5.5 kB);仍低于 `< 80 kB gzip` 页面预算,余量约 15 kB | | `ProvidersPage` | 121.98 kB | 30.85 kB | 已低于 `< 80 kB gzip` 页面预算 | | `MonitorPage` | 24.33 kB | 6.80 kB | 已拆为 toolbar / state hook / dashboard sections | | `LogsPage` | 22.15 kB | 6.51 kB | 已拆为 live logs / error logs / helpers | @@ -37,7 +37,7 @@ bun run build | 页面/模块 | 当前体积 | 预算状态 | 最近治理结果 | | ----------------- | ------------------------: | -------------- | --------------------------------------------------------------------------------------------------------------- | -| `AuthFilesPage` | 216.81 kB / 58.90 kB gzip | 通过 | 新增 Codex 身份多档案与出站选择后仍低于页面 gzip 预算,后续继续关注 chunk 治理 | +| `AuthFilesPage` | 234.90 kB / 64.36 kB gzip | 通过 | 基线与 dev 现状对齐;仍低于页面 gzip 预算,余量约 15 kB,chunk 治理继续关注 | | `ConfigPage` | 119.60 kB / 33.35 kB gzip | 通过 | 已拆出 runtime panel / visual payload editors,并复用 feature 侧 visual config hook | | `ProvidersPage` | 121.98 kB / 30.85 kB gzip | 通过且低于预算 | OpenAI tab、usage summary、provider editor hooks 已完成拆分 | | `MonitorPage` | 24.33 kB / 6.80 kB gzip | 通过且低于预算 | 拆出 `MonitorToolbarSection`、`MonitorDashboardSections`、`useMonitorDashboardState` | diff --git a/e2e/video-generation-page.spec.ts b/e2e/video-generation-page.spec.ts new file mode 100644 index 00000000..7cda0cba --- /dev/null +++ b/e2e/video-generation-page.spec.ts @@ -0,0 +1,134 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** + * Renders the video models page against mocked management APIs. + * + * Not marked @critical: the page's behaviour is covered by unit tests, and this + * spec exists so the layout — highlighted curl block, endpoint switch, spec + * tables — is exercised in a real browser rather than only in jsdom. + */ + +const VIDEO_MODEL = { + id: "grok-imagine-video-1.5", + provider: "xai", + display_name: "Grok Imagine Video", + description: "Grok Imagine text-to-video and image-to-video generation.", + supports_image_to_video: true, + max_duration_seconds: 15, +}; + +const seedAuth = async (page: Page) => { + await page.addInitScript(() => { + sessionStorage.setItem( + "code-proxy-admin-auth", + JSON.stringify({ + apiBase: "http://127.0.0.1:8317", + managementKey: "cps_test", + rememberPassword: false, + expiresAt: Date.now() + 60_000, + }), + ); + localStorage.setItem( + "cli-proxy-language", + JSON.stringify({ language: "zh-CN", state: { language: "zh-CN" } }), + ); + }); +}; + +const mockApis = async (page: Page) => { + const tenant = { + id: "t-system", + slug: "system", + name: "System Administration", + type: "system", + status: "active", + effective_status: "active", + expires_at: null, + description: "", + version: 1, + created_at: "", + updated_at: "", + }; + await page.route("**/v0/auth/me", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + principal: { + kind: "user_session", + user: { + id: "u-admin", + tenant_id: "t-system", + username: "admin", + display_name: "Super Administrator", + status: "active", + must_change_password: false, + last_login_at: null, + role_ids: ["r-platform-admin"], + role_codes: ["platform_super_admin"], + version: 1, + created_at: "", + updated_at: "", + }, + home_tenant: tenant, + effective_tenant: tenant, + roles: [], + permissions: ["system.config.read", "dashboard.read"], + platform_admin: true, + }, + }), + }), + ); + // Order matters: Playwright tries the most recently registered route first, so + // the catch-all has to be registered before the specific one it must not shadow. + await page.route("**/v0/management/**", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + ); + await page.route("**/v0/management/video-generation/models", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ models: [VIDEO_MODEL] }), + }), + ); +}; + +test("renders the video call docs with a highlighted snippet", async ({ page }) => { + await seedAuth(page); + await mockApis(page); + await page.goto("/#/models/video-generation"); + + await expect(page.getByRole("heading", { name: "视频模型" })).toBeVisible(); + + const codeBlock = page.locator("[data-code-block]").first(); + await expect(codeBlock).toContainText("curl http://127.0.0.1:8317/v1/videos/generations"); + await expect(codeBlock).toContainText("/v1/videos/$REQUEST_ID"); + + // Highlighting must produce coloured token spans, not one flat text node. + const tokenColours = await codeBlock.evaluate((element) => { + const spans = [...element.querySelectorAll("span")]; + return new Set(spans.map((span) => getComputedStyle(span).color)).size; + }); + expect(tokenColours).toBeGreaterThan(2); + + await page.getByRole("tab", { name: "图生视频" }).click(); + await expect(page.locator("[data-code-block]").first()).toContainText('"image": { "url"'); + + await expect(page.getByText("请求参数")).toBeVisible(); + await expect(page.getByText("返回结构")).toBeVisible(); +}); + +test("enables the test panel from the served model catalog", async ({ page }) => { + await seedAuth(page); + await mockApis(page); + await page.goto("/#/models/video-generation"); + + // The button stays disabled until the catalog answers, which is what made the + // panel unreachable when the models call was shadowed by a catch-all mock. + const testButton = page.getByRole("button", { name: "测试生成" }); + await expect(testButton).toBeEnabled(); + + await testButton.click(); + await expect(page.getByText("测试视频生成")).toBeVisible(); + await expect(page.getByText(VIDEO_MODEL.id, { exact: false }).first()).toBeVisible(); +}); diff --git a/features/api-key-restrictions/useApiKeyPermissionOptions.tsx b/features/api-key-restrictions/useApiKeyPermissionOptions.tsx index a817a9d3..20e8a9b7 100644 --- a/features/api-key-restrictions/useApiKeyPermissionOptions.tsx +++ b/features/api-key-restrictions/useApiKeyPermissionOptions.tsx @@ -132,6 +132,7 @@ export function useApiKeyPermissionOptions() { openCodeGoKeys, clineKeys, ollamaCloudKeys, + commandCodeKeys, vertexKeys, openaiProviders, authFiles, @@ -142,6 +143,7 @@ export function useApiKeyPermissionOptions() { providersApi.getOpenCodeGoConfigs().catch(() => []), providersApi.getClineConfigs().catch(() => []), providersApi.getOllamaCloudConfigs().catch(() => []), + providersApi.getCommandCodeConfigs().catch(() => []), providersApi.getVertexConfigs().catch(() => []), providersApi.getOpenAIProviders().catch(() => []), authFilesApi.list().catch(() => ({ files: [] })), @@ -173,6 +175,7 @@ export function useApiKeyPermissionOptions() { openCodeGoKeys.forEach((item) => push(item.name || "", "API", "opencode-go")); clineKeys.forEach((item) => push(item.name || "", "API", "cline")); ollamaCloudKeys.forEach((item) => push(item.name || "", "API", "ollama-cloud")); + commandCodeKeys.forEach((item) => push(item.name || "", "API", "commandcode")); vertexKeys.forEach((item) => push(item.name || "", "API", "vertex")); openaiProviders.forEach((item) => push(item.name || "", "API", "openai")); (authFiles.files || []).forEach((file) => { diff --git a/features/model-availability/modelAvailability.ts b/features/model-availability/modelAvailability.ts index 55a9f94b..c6fcc2e2 100644 --- a/features/model-availability/modelAvailability.ts +++ b/features/model-availability/modelAvailability.ts @@ -131,6 +131,7 @@ const PROVIDER_CHANNELS = [ { key: "cline", load: () => providersApi.getClineConfigs() }, { key: "opencode-go", load: () => providersApi.getOpenCodeGoConfigs() }, { key: "ollama-cloud", load: () => providersApi.getOllamaCloudConfigs() }, + { key: "commandcode", load: () => providersApi.getCommandCodeConfigs() }, { key: "vertex", load: () => providersApi.getVertexConfigs() }, ] as const; @@ -138,6 +139,7 @@ const MODEL_ACCESS_PROVIDER_KEYS = new Set([ "cline", "opencode-go", "ollama-cloud", + "commandcode", ]); const emptyAvailability = ( diff --git a/features/model-tags/index.tsx b/features/model-tags/index.tsx index e9c53080..60432168 100644 --- a/features/model-tags/index.tsx +++ b/features/model-tags/index.tsx @@ -365,7 +365,10 @@ export function ModelTag({ return ( { mocks.downloadText.mockResolvedValueOnce( JSON.stringify({ project_id: "bamboo-precept-lgxtn" }), ); - mocks.request.mockResolvedValueOnce({ + const antigravityModelsResponse = { statusCode: 200, header: {}, bodyText: "", @@ -227,7 +227,15 @@ describe("fetchQuota for antigravity", () => { }, ], }), - }); + }; + + // The grouped summary is asked for first; this account's upstream does not + // serve it, so the flat model list is what the card ends up rendering. + mocks.request.mockImplementation(async ({ url }: { url: string }) => + url.includes("retrieveUserQuotaSummary") + ? { statusCode: 404, header: {}, bodyText: "", body: "" } + : antigravityModelsResponse, + ); const result = await fetchQuota("antigravity", { name: "antigravity.json", @@ -236,43 +244,169 @@ describe("fetchQuota for antigravity", () => { } as any); expect(mocks.downloadText).toHaveBeenCalledWith("antigravity.json"); + // Sandbox is tried first, and the client version must be one the upstream + // still serves the full model set to. expect(mocks.request).toHaveBeenCalledWith( expect.objectContaining({ authIndex: "ag-1", method: "POST", - url: "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", + url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels", data: JSON.stringify({ project: "bamboo-precept-lgxtn" }), header: expect.objectContaining({ Authorization: "Bearer $TOKEN$", - "User-Agent": "antigravity/1.11.5 windows/amd64", + "User-Agent": "vscode/1.X.X (Antigravity/4.3.0)", }), }), ); expect(result.items.map((item) => item.key)).toEqual([ - "provider:gemini3-pro", - "provider:gemini3-flash", - "provider:claude", + "antigravity:gemini_pro", + "antigravity:gemini_flash", + "antigravity:claude", + "antigravity:model_gpt_oss_120b_medium", ]); expect(result.items[0]).toEqual( expect.objectContaining({ - label: "antigravity_quota.gemini3_pro", + label: "Gemini Pro", percent: 80, resetAtMs: Date.parse("2026-05-09T15:50:29Z"), }), ); - expect(result.items[1]).toEqual( - expect.objectContaining({ - label: "antigravity_quota.gemini3_flash", - percent: 70, - }), + expect(result.items[1]).toEqual(expect.objectContaining({ label: "Gemini Flash", percent: 70 })); + expect(result.items[2]).toEqual(expect.objectContaining({ label: "Claude", percent: 60 })); + expect(result.items[3]).toEqual( + expect.objectContaining({ label: "GPT-OSS 120B (Medium)", percent: 50 }), ); - expect(result.items[2]).toEqual( + expect(result.items[0].meta).toBeUndefined(); + }); + + test("prefers the grouped summary and never falls back when it answers", async () => { + mocks.downloadText.mockResolvedValueOnce(JSON.stringify({ project_id: "real-project" })); + mocks.request.mockImplementation(async ({ url }: { url: string }) => { + if (!url.includes("retrieveUserQuotaSummary")) { + throw new Error(`unexpected fallback request to ${url}`); + } + return { + statusCode: 200, + header: {}, + bodyText: "", + body: JSON.stringify({ + groups: [ + { + displayName: "Gemini Models", + buckets: [ + { bucketId: "gemini-5h", window: "5h", remainingFraction: 0.72 }, + { bucketId: "gemini-weekly", window: "weekly", remainingFraction: 0.51 }, + ], + }, + ], + }), + }; + }); + + const result = await fetchQuota("antigravity", { + name: "antigravity.json", + provider: "antigravity", + auth_index: "ag-1", + } as any); + + expect(result.items.map((item) => item.key)).toEqual([ + "antigravity:gemini_5h", + "antigravity:gemini_weekly", + ]); + expect(result.items.map((item) => item.windowSeconds)).toEqual([ + 5 * 60 * 60, + 7 * 24 * 60 * 60, + ]); + }); + + // A project the account cannot read is rejected outright. Retrying without it + // is what the upstream client does, and it is the difference between a card + // that reads "forbidden" and one that shows the account's real quota. + test("retries without the project field after a 403", async () => { + mocks.downloadText.mockResolvedValueOnce(JSON.stringify({ project_id: "foreign-project" })); + const seen: Array<{ url: string; data: string }> = []; + mocks.request.mockImplementation(async ({ url, data }: { url: string; data: string }) => { + seen.push({ url, data }); + if (url.includes("retrieveUserQuotaSummary")) { + if (data.includes("foreign-project")) { + return { statusCode: 403, header: {}, bodyText: "", body: "" }; + } + return { + statusCode: 200, + header: {}, + bodyText: "", + body: JSON.stringify({ + groups: [ + { + displayName: "Gemini Models", + buckets: [{ bucketId: "gemini-5h", window: "5h", remainingFraction: 0.4 }], + }, + ], + }), + }; + } + return { statusCode: 404, header: {}, bodyText: "", body: "" }; + }); + + const result = await fetchQuota("antigravity", { + name: "antigravity.json", + provider: "antigravity", + auth_index: "ag-1", + } as any); + + expect(seen[0].data).toBe(JSON.stringify({ project: "foreign-project" })); + expect(seen[1].data).toBe("{}"); + expect(seen[1].url).toBe(seen[0].url); + expect(result.items[0]).toEqual(expect.objectContaining({ percent: 40 })); + }); + + // Without a stored project the account's own one has to be looked up; guessing + // reports some other project's remaining fraction. + test("looks the project up via loadCodeAssist when the auth file has none", async () => { + mocks.downloadText.mockResolvedValueOnce(JSON.stringify({ client_id: "x" })); + const seen: string[] = []; + mocks.request.mockImplementation(async ({ url }: { url: string }) => { + seen.push(url); + if (url.includes("loadCodeAssist")) { + return { + statusCode: 200, + header: {}, + bodyText: "", + body: JSON.stringify({ cloudaicompanionProject: "discovered-project" }), + }; + } + if (url.includes("retrieveUserQuotaSummary")) { + return { + statusCode: 200, + header: {}, + bodyText: "", + body: JSON.stringify({ + groups: [ + { + displayName: "Gemini Models", + buckets: [{ bucketId: "gemini-5h", window: "5h", remainingFraction: 0.2 }], + }, + ], + }), + }; + } + return { statusCode: 404, header: {}, bodyText: "", body: "" }; + }); + + const result = await fetchQuota("antigravity", { + name: "antigravity.json", + provider: "antigravity", + auth_index: "ag-1", + } as any); + + expect(seen[0]).toContain("loadCodeAssist"); + expect(mocks.request).toHaveBeenCalledWith( expect.objectContaining({ - label: "antigravity_quota.claude", - percent: 60, + url: expect.stringContaining("retrieveUserQuotaSummary"), + data: JSON.stringify({ project: "discovered-project" }), }), ); - expect(result.items[0].meta).toBeUndefined(); + expect(result.items[0]).toEqual(expect.objectContaining({ percent: 20 })); }); }); diff --git a/features/quota-preview/__tests__/quota-helpers.test.ts b/features/quota-preview/__tests__/quota-helpers.test.ts index 2c26a375..ef981b27 100644 --- a/features/quota-preview/__tests__/quota-helpers.test.ts +++ b/features/quota-preview/__tests__/quota-helpers.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { buildAntigravityItems, + buildAntigravitySummaryItems, buildCodexItems, buildKimiItems, filterAntigravityQuotaItems, @@ -300,51 +301,68 @@ describe("buildAntigravityItems", () => { const items = buildAntigravityItems(payload!); const labels = items.map((item) => item.label); + // Families are classified by the shape of the model id, so gpt-oss — which + // belongs to none of them — is reported under its own name instead of being + // dropped for missing from a list. expect(items.map((item) => item.key)).toEqual([ - "provider:gemini3-pro", - "provider:gemini3-flash", - "provider:gemini-image", - "provider:claude", + "antigravity:gemini_pro", + "antigravity:gemini_flash", + "antigravity:gemini_image", + "antigravity:claude", + "antigravity:model_gpt_oss_120b_medium", ]); expect(labels).toEqual([ - "antigravity_quota.gemini3_pro", - "antigravity_quota.gemini3_flash", - "antigravity_quota.gemini_image", - "antigravity_quota.claude", + "Gemini Pro", + "Gemini Flash", + "Gemini Image", + "Claude", + "GPT-OSS 120B (Medium)", ]); - expect(labels).not.toContain("Gemini 3.1 Pro (High) [gemini-3.1-pro-high]"); - expect(labels).not.toContain("GPT-OSS 120B (Medium) [gpt-oss-120b-medium]"); + // Internal entries are still filtered, by prefix rather than by id. expect(labels).not.toContain("chat_20706"); expect(labels).not.toContain("chat_23310"); expect(labels).not.toContain("tab_flash_lite_preview"); expect(labels).not.toContain("tab_jump_flash_lite_preview"); - expect(labels).not.toContain("Gemini 3.1 Flash Lite [gemini-2.5-flash-thinking]"); - expect(labels).not.toContain("Gemini 2.5 Pro [gemini-2.5-pro]"); + + // Worst remaining within a family: gemini-3.1-pro-low at 50 beats + // gemini-3.1-pro-high at 75 and gemini-2.5-pro at 100. expect(items[0]).toEqual( expect.objectContaining({ percent: 50, resetAtMs: Date.parse("2026-05-09T15:50:29Z"), + windowSeconds: 5 * 60 * 60, }), ); - expect(items[1]).toEqual( - expect.objectContaining({ - percent: 70, - }), - ); - expect(items[2]).toEqual( - expect.objectContaining({ - percent: 60, - }), - ); - expect(items[3]).toEqual( - expect.objectContaining({ - percent: 90, + expect(items[1]).toEqual(expect.objectContaining({ percent: 70 })); + expect(items[2]).toEqual(expect.objectContaining({ percent: 60 })); + expect(items[3]).toEqual(expect.objectContaining({ percent: 90 })); + expect(items[4]).toEqual(expect.objectContaining({ percent: 80 })); + expect(items[0].meta).toBeUndefined(); + }); + + test("groups a model family the upstream ships later without a code change", () => { + const payload = parseAntigravityPayload( + JSON.stringify({ + models: { + "gemini-4-pro-ultra": { displayName: "Gemini 4 Pro", quotaInfo: { remainingFraction: 0.3 } }, + "gemini-4-flash-nano": { quotaInfo: { remainingFraction: 0.4 } }, + "claude-opus-9": { quotaInfo: { remainingFraction: 0.55 } }, + }, }), ); - expect(items[0].meta).toBeUndefined(); + const items = buildAntigravityItems(payload!); + expect(items.map((item) => item.key)).toEqual([ + "antigravity:gemini_pro", + "antigravity:gemini_flash", + "antigravity:claude", + ]); + expect(items.map((item) => item.percent)).toEqual([30, 40, 55]); }); - test("keeps cached sub2api-style Antigravity summaries when cache only has labels", () => { + // Rows cached under the previous grouping still have to render while they age + // out, so the old keys and labels are recognised on read and mapped onto the + // current families. + test("re-reads cached rows written under the previous Antigravity grouping", () => { expect( filterAntigravityQuotaItems([ { label: "antigravity_quota.gemini3_pro", percent: 82 }, @@ -353,32 +371,92 @@ describe("buildAntigravityItems", () => { { label: "antigravity_quota.claude", percent: 73 }, ]), ).toEqual([ + { key: "antigravity:gemini_pro", label: "Gemini Pro", percent: 82, windowSeconds: 5 * 60 * 60 }, { - key: "provider:gemini3-pro", - label: "antigravity_quota.gemini3_pro", - percent: 82, - resetAtMs: undefined, - }, - { - key: "provider:gemini3-flash", - label: "antigravity_quota.gemini3_flash", + key: "antigravity:gemini_flash", + label: "Gemini Flash", percent: 77, - resetAtMs: undefined, + windowSeconds: 5 * 60 * 60, }, { - key: "provider:gemini-image", - label: "antigravity_quota.gemini_image", + key: "antigravity:gemini_image", + label: "Gemini Image", percent: 65, - resetAtMs: undefined, + windowSeconds: 5 * 60 * 60, }, + { key: "antigravity:claude", label: "Claude", percent: 73, windowSeconds: 5 * 60 * 60 }, + ]); + }); + + test("passes grouped summary rows through untouched", () => { + const summaryItems = [ { - key: "provider:claude", - label: "antigravity_quota.claude", - percent: 73, - resetAtMs: undefined, + key: "antigravity:gemini_5h", + label: "Gemini Models · 5h", + percent: 72, + windowSeconds: 5 * 60 * 60, + }, + { + key: "antigravity:gemini_weekly", + label: "Gemini Models · weekly", + percent: 51, + windowSeconds: 7 * 24 * 60 * 60, + }, + ]; + expect(filterAntigravityQuotaItems(summaryItems)).toEqual(summaryItems); + }); +}); + +describe("buildAntigravitySummaryItems", () => { + test("reads the upstream's own buckets and window widths", () => { + const items = buildAntigravitySummaryItems({ + groups: [ + { + displayName: "Gemini Models", + buckets: [ + { + bucketId: "gemini-5h", + window: "5h", + remainingFraction: 0.72, + resetTime: "2026-08-19T07:00:00Z", + }, + { bucketId: "gemini-weekly", window: "weekly", remainingFraction: 0.51 }, + ], + }, + { + displayName: "Claude and GPT models", + buckets: [{ bucketId: "3p-5h", window: "5h", remainingFraction: 1 }], + }, + ], + }); + + expect(items).toEqual([ + { + key: "antigravity:gemini_5h", + label: "Gemini Models · 5h", + percent: 72, + resetAtMs: Date.parse("2026-08-19T07:00:00Z"), + windowSeconds: 5 * 60 * 60, + }, + { + key: "antigravity:gemini_weekly", + label: "Gemini Models · weekly", + percent: 51, + windowSeconds: 7 * 24 * 60 * 60, + }, + { + key: "antigravity:3p_5h", + label: "Claude and GPT models · 5h", + percent: 100, + windowSeconds: 5 * 60 * 60, }, ]); }); + + test("returns nothing when the payload carries no groups", () => { + expect(buildAntigravitySummaryItems({ models: { "gemini-3-pro": {} } })).toEqual([]); + expect(buildAntigravitySummaryItems(null)).toEqual([]); + }); }); describe("buildKimiItems", () => { diff --git a/features/quota-preview/index.ts b/features/quota-preview/index.ts index d27ad6de..d7adf5fc 100644 --- a/features/quota-preview/index.ts +++ b/features/quota-preview/index.ts @@ -1,10 +1,16 @@ export type { AntigravityModelsPayload } from "./quota-helpers"; export { + ANTIGRAVITY_QUOTA_KEY_PREFIX, buildAntigravityGroups, buildAntigravityItems, + buildAntigravitySummaryItems, + categorizeAntigravityModel, filterAntigravityQuotaItems, + parseAntigravityForwardingRules, parseAntigravityPayload, + parseAntigravityWindowSeconds, shouldSkipAntigravityModelId, + type AntigravityQuotaCategory, } from "./quota-helpers"; export { parseIdTokenPayload, type QuotaItem, type QuotaState } from "./quota-helpers"; export type { QuotaProvider } from "./quota-fetch"; diff --git a/features/quota-preview/quota-antigravity.ts b/features/quota-preview/quota-antigravity.ts index 7ef61986..4665ec12 100644 --- a/features/quota-preview/quota-antigravity.ts +++ b/features/quota-preview/quota-antigravity.ts @@ -22,6 +22,7 @@ export type AntigravityModelsPayload = Record; export type AntigravityFetchAvailableModelsPayload = { models?: AntigravityModelsPayload; + deprecatedModelIds?: unknown; defaultAgentModelId?: unknown; agentModelSorts?: unknown; commandModelIds?: unknown; @@ -41,173 +42,200 @@ const MODEL_ID_LISTS: Array = [ "commitMessageModelIds", ]; -const REFERENCE_SKIPPED_MODEL_IDS = new Set([ - "chat_20706", - "chat_23310", - "tab_flash_lite_preview", - "tab_jump_flash_lite_preview", - "gemini-2.5-flash-thinking", - "gemini-2.5-pro", -]); +export const ANTIGRAVITY_QUOTA_KEY_PREFIX = "antigravity:"; + +const FIVE_HOUR_SECONDS = 5 * 60 * 60; +const WEEK_SECONDS = 7 * 24 * 60 * 60; +const DAY_SECONDS = 24 * 60 * 60; +const MONTH_SECONDS = 30 * 24 * 60 * 60; const normalizeModelId = (value: unknown): string | null => normalizeStringValue(value); const normalizeModelIdList = (value: unknown): string[] => Array.isArray(value) ? value.map(normalizeModelId).filter((id): id is string => Boolean(id)) : []; -export const shouldSkipAntigravityModelId = (id: string): boolean => - REFERENCE_SKIPPED_MODEL_IDS.has(id); - -const ANTIGRAVITY_MODEL_KEY_PREFIX = "model:"; -const ANTIGRAVITY_SUMMARY_KEY_PREFIX = "provider:"; - -type AntigravityQuotaGroup = "gemini3Pro" | "gemini3Flash" | "gemini3Image" | "claude"; - -const ANTIGRAVITY_QUOTA_GROUPS: Array<{ - group: AntigravityQuotaGroup; - key: string; - label: string; -}> = [ - { group: "gemini3Pro", key: "provider:gemini3-pro", label: "antigravity_quota.gemini3_pro" }, - { - group: "gemini3Flash", - key: "provider:gemini3-flash", - label: "antigravity_quota.gemini3_flash", - }, - { group: "gemini3Image", key: "provider:gemini-image", label: "antigravity_quota.gemini_image" }, - { group: "claude", key: "provider:claude", label: "antigravity_quota.claude" }, -]; - -const ANTIGRAVITY_QUOTA_GROUP_MODEL_IDS: Record> = { - gemini3Pro: new Set([ - "gemini-3-pro-low", - "gemini-3-pro-high", - "gemini-3-pro-preview", - "gemini-3.1-pro-low", - "gemini-3.1-pro-high", - "gemini-3.1-pro-preview", - ]), - gemini3Flash: new Set(["gemini-3-flash", "gemini-3-flash-agent"]), - gemini3Image: new Set([ - "gemini-2.5-flash-image", - "gemini-3.1-flash-image", - "gemini-3-pro-image", - "gemini-3-pro-image-preview", - ]), - claude: new Set([ - "claude-fable-5", - "claude-sonnet-4-5", - "claude-sonnet-4-5-thinking", - "claude-opus-4-5-thinking", - "claude-sonnet-4-6", - "claude-opus-4-6", - "claude-opus-4-6-thinking", - "claude-opus-4-7", - "claude-opus-4-8", - ]), -}; - -const normalizeAntigravityModelIdForGroup = (value: string): string => { +const normalizeAntigravityModelId = (value: string): string => { const normalized = value.trim().toLowerCase(); return normalized.startsWith("models/") ? normalized.slice("models/".length) : normalized; }; -const resolveAntigravityModelIdFromQuotaItem = (item: QuotaItem): string | null => { - const key = typeof item.key === "string" ? item.key.trim() : ""; - if (key.startsWith(ANTIGRAVITY_MODEL_KEY_PREFIX)) { - return key.slice(ANTIGRAVITY_MODEL_KEY_PREFIX.length).trim() || null; - } - if (key && shouldSkipAntigravityModelId(key)) return key; - - const label = String(item.label ?? "").trim(); - const bracketModelId = label.match(/\[([^\]]+)\]\s*$/)?.[1]?.trim(); - if (bracketModelId) return bracketModelId; - return label && shouldSkipAntigravityModelId(label) ? label : null; -}; - -const resolveAntigravityQuotaGroupFromModelId = (id: string): AntigravityQuotaGroup | null => { - const modelId = normalizeAntigravityModelIdForGroup(id); - const match = ANTIGRAVITY_QUOTA_GROUPS.find(({ group }) => - ANTIGRAVITY_QUOTA_GROUP_MODEL_IDS[group].has(modelId), - ); - return match?.group ?? null; +/** + * The upstream's non-conversational entries. Matched by shape rather than by id + * so a newly added internal model does not surface as a user-visible row. + */ +export const shouldSkipAntigravityModelId = (id: string): boolean => { + const normalized = normalizeAntigravityModelId(id); + return normalized.startsWith("chat_") || normalized.startsWith("tab_"); }; -const resolveAntigravityQuotaGroupFromSummaryValue = ( - value: string, -): AntigravityQuotaGroup | null => - ANTIGRAVITY_QUOTA_GROUPS.find((group) => group.key === value || group.label === value)?.group ?? - null; - -const resolveAntigravityQuotaGroupFromItem = (item: QuotaItem): AntigravityQuotaGroup | null => { - const key = typeof item.key === "string" ? item.key.trim() : ""; - if (key.startsWith(ANTIGRAVITY_SUMMARY_KEY_PREFIX)) { - const group = resolveAntigravityQuotaGroupFromSummaryValue(key); - if (group) return group; +export type AntigravityQuotaCategory = + | "gemini_pro" + | "gemini_flash" + | "gemini_image" + | "claude" + | "other"; + +/** + * Group a model by the shape of its id rather than by membership in a list. + * + * A list has to be edited every time the upstream ships a model, and until it + * is, the new model's quota is dropped on the floor — it does not render as + * "unknown", it renders as nothing at all. The shapes below already cover the + * models that do not exist yet, and anything they still miss lands in `other` + * and is shown under its own name. + */ +export const categorizeAntigravityModel = (rawId: string): AntigravityQuotaCategory => { + const id = normalizeAntigravityModelId(rawId); + const isGemini = id.startsWith("gemini"); + if ((isGemini && id.includes("image")) || id.startsWith("image") || id.startsWith("imagen")) { + return "gemini_image"; } - const label = String(item.label ?? "").trim(); - const labelGroup = resolveAntigravityQuotaGroupFromSummaryValue(label); - if (labelGroup) return labelGroup; - const modelId = resolveAntigravityModelIdFromQuotaItem(item); - return modelId ? resolveAntigravityQuotaGroupFromModelId(modelId) : null; + if (isGemini && id.includes("flash")) return "gemini_flash"; + if (isGemini && id.includes("pro")) return "gemini_pro"; + if ( + id.includes("claude") || + id.includes("opus") || + id.includes("sonnet") || + id.includes("haiku") + ) { + return "claude"; + } + return "other"; }; -const resolveAntigravityQuotaGroupFromModel = (id: string): AntigravityQuotaGroup | null => - resolveAntigravityQuotaGroupFromModelId(id); - -const earlierResetAtMs = (current: number | undefined, next: number | undefined) => { - if (typeof next !== "number" || !Number.isFinite(next)) return current; - if (typeof current !== "number" || !Number.isFinite(current)) return next; - return Math.min(current, next); +const CATEGORY_LABELS: Record, string> = { + gemini_pro: "Gemini Pro", + gemini_flash: "Gemini Flash", + gemini_image: "Gemini Image", + claude: "Claude", }; -export const summarizeAntigravityQuotaItems = (items: QuotaItem[]): QuotaItem[] => { - const grouped = new Map< - AntigravityQuotaGroup, - { percent: number | null; resetAtMs?: number; count: number } - >(); - - items.forEach((item) => { - const modelId = resolveAntigravityModelIdFromQuotaItem(item); - if (modelId && shouldSkipAntigravityModelId(modelId)) return; - - const group = resolveAntigravityQuotaGroupFromItem(item); - if (!group) return; +const CATEGORY_RANK: Record = { + gemini_pro: 0, + gemini_flash: 1, + gemini_image: 2, + claude: 3, + other: 4, +}; - const existing = grouped.get(group) ?? { percent: null, count: 0 }; - const percent = - typeof item.percent === "number" && Number.isFinite(item.percent) - ? clampPercent(item.percent) - : null; +const normalizeKeyPart = (value: string): string => + value + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "_") + .replaceAll(/^_+|_+$/g, ""); + +/** + * Map the upstream's window token onto a duration. An unrecognised token yields + * `undefined` rather than a guess, so a window we cannot classify is still shown + * — it just never claims to be the weekly cycle. + */ +export const parseAntigravityWindowSeconds = ( + window: string | undefined, + bucketId?: string, +): number | undefined => { + const candidates = [window, bucketId] + .map((value) => (value ?? "").toLowerCase().replaceAll(/[\s_-]/g, "")) + .filter(Boolean); + + for (const candidate of candidates) { + if (candidate.includes("week")) return WEEK_SECONDS; + if (candidate.includes("month")) return MONTH_SECONDS; + if (candidate.includes("day") || candidate.includes("daily")) return DAY_SECONDS; + const hours = candidate.match(/(\d+)h/)?.[1]; + if (hours) { + const parsed = Number.parseInt(hours, 10); + if (Number.isFinite(parsed) && parsed > 0 && parsed <= 24 * 31) return parsed * 60 * 60; + } + } + return undefined; +}; - grouped.set(group, { - percent: - percent === null - ? existing.percent - : existing.percent === null - ? percent - : Math.min(existing.percent, percent), - resetAtMs: earlierResetAtMs(existing.resetAtMs, item.resetAtMs), - count: existing.count + 1, +// ── retrieveUserQuotaSummary ──────────────────────────────────────────────── + +/** + * Read the grouped summary. Every label and every grouping comes from the + * payload: the account's real buckets are `gemini-weekly`, `gemini-5h`, + * `3p-weekly` and `3p-5h`, and splitting them further by model id is what made + * one bucket render as several rows all showing the same number. + */ +export const buildAntigravitySummaryItems = (payload: unknown): QuotaItem[] => { + if (!isRecord(payload)) return []; + const groups = payload.groups ?? payload.quotaGroups ?? payload.quota_groups; + if (!Array.isArray(groups)) return []; + + const items: QuotaItem[] = []; + const seen = new Set(); + + groups.forEach((rawGroup, groupIndex) => { + if (!isRecord(rawGroup)) return; + const groupName = + normalizeStringValue(rawGroup.displayName ?? rawGroup.display_name) ?? undefined; + const buckets = rawGroup.buckets ?? rawGroup.quotaBuckets ?? rawGroup.quota_buckets; + if (!Array.isArray(buckets)) return; + + buckets.forEach((rawBucket, bucketIndex) => { + if (!isRecord(rawBucket)) return; + const fraction = normalizeQuotaFraction( + rawBucket.remainingFraction ?? rawBucket.remaining_fraction ?? rawBucket.remaining, + ); + const resetTimeRaw = rawBucket.resetTime ?? rawBucket.reset_time; + const resetAtMs = + typeof resetTimeRaw === "string" ? parseResetTimeToMs(resetTimeRaw) : undefined; + if (fraction === null && resetAtMs === undefined) return; + + const bucketId = + normalizeStringValue(rawBucket.bucketId ?? rawBucket.bucket_id ?? rawBucket.id) ?? ""; + const window = normalizeStringValue(rawBucket.window) ?? ""; + const bucketName = + normalizeStringValue(rawBucket.displayName ?? rawBucket.display_name) ?? undefined; + + let keyPart = normalizeKeyPart(bucketId); + if (!keyPart) { + const windowPart = normalizeKeyPart(window); + keyPart = windowPart ? `g${groupIndex}_${windowPart}` : `g${groupIndex}_b${bucketIndex}`; + } + const key = `${ANTIGRAVITY_QUOTA_KEY_PREFIX}${keyPart}`; + if (seen.has(key)) return; + seen.add(key); + + items.push({ + key, + label: buildSummaryLabel(groupName, bucketName, bucketId, window), + percent: fraction === null ? null : Math.round(clampPercent(fraction * 100)), + ...(resetAtMs === undefined ? {} : { resetAtMs }), + ...(parseAntigravityWindowSeconds(window, bucketId) === undefined + ? {} + : { windowSeconds: parseAntigravityWindowSeconds(window, bucketId) }), + }); }); }); - return ANTIGRAVITY_QUOTA_GROUPS.flatMap(({ group, key, label }) => { - const summary = grouped.get(group); - if (!summary || summary.count === 0) return []; - return [ - { - key, - label, - percent: summary.percent, - resetAtMs: summary.resetAtMs, - }, - ]; - }); + return items; }; -export const filterAntigravityQuotaItems = (items: QuotaItem[]): QuotaItem[] => - summarizeAntigravityQuotaItems(items); +/** + * Keep the upstream's own wording. Translating it here would mean maintaining a + * table of names the upstream is free to change without telling us. + */ +const buildSummaryLabel = ( + groupName: string | undefined, + bucketName: string | undefined, + bucketId: string, + window: string, +): string => { + if (bucketName) { + if (groupName && groupName.toLowerCase() !== bucketName.toLowerCase()) { + return `${groupName} · ${bucketName}`; + } + return bucketName; + } + const base = groupName || bucketId; + if (!base) return window; + return window ? `${base} · ${window}` : base; +}; + +// ── fetchAvailableModels ──────────────────────────────────────────────────── const resolvePayloadAndModels = ( input: AntigravityFetchAvailableModelsPayload | AntigravityModelsPayload, @@ -266,15 +294,16 @@ const collectPayloadModelOrder = (payload: AntigravityFetchAvailableModelsPayloa return order; }; -const buildModelLabel = (id: string, entry: AntigravityQuotaInfo): string => { - const displayName = normalizeStringValue(entry.displayName); - if (!displayName || displayName === id) return id; - return `${displayName} [${id}]`; +type ModelEntry = { + id: string; + displayName?: string; + percent: number | null; + resetAtMs?: number; }; -const buildAntigravityModelItems = ( +const collectModelEntries = ( input: AntigravityFetchAvailableModelsPayload | AntigravityModelsPayload, -): QuotaItem[] => { +): ModelEntry[] => { const { payload, models } = resolvePayloadAndModels(input); const order = collectPayloadModelOrder(payload); const orderedIds = new Set(order); @@ -291,28 +320,199 @@ const buildAntigravityModelItems = ( return order.flatMap((id) => { const entry = models[id]; if (!entry) return []; - if (!resolveAntigravityQuotaGroupFromModel(id)) return []; const info = quotaInfo(entry); if (info.remainingFraction === null && !info.resetTime) return []; - const percent = - info.remainingFraction === null - ? null - : Math.round(clampPercent(info.remainingFraction * 100)); - + const resetAtMs = parseResetTimeToMs(info.resetTime); return [ { - key: `model:${id}`, - label: buildModelLabel(id, entry), - percent, - resetAtMs: parseResetTimeToMs(info.resetTime), + id: normalizeAntigravityModelId(id), + displayName: normalizeStringValue(entry.displayName) ?? undefined, + percent: + info.remainingFraction === null + ? null + : Math.round(clampPercent(info.remainingFraction * 100)), + ...(resetAtMs === undefined ? {} : { resetAtMs }), }, ]; }); }; +const earlierResetAtMs = (current: number | undefined, next: number | undefined) => { + if (typeof next !== "number" || !Number.isFinite(next)) return current; + if (typeof current !== "number" || !Number.isFinite(current)) return next; + return Math.min(current, next); +}; + +type GroupAccumulator = { + key: string; + label: string; + rank: number; + percent: number | null; + resetAtMs?: number; + count: number; +}; + +const groupModelEntries = (entries: ModelEntry[]): QuotaItem[] => { + const grouped = new Map(); + + entries.forEach((entry) => { + const category = categorizeAntigravityModel(entry.id); + const key = + category === "other" + ? `${ANTIGRAVITY_QUOTA_KEY_PREFIX}model_${normalizeKeyPart(entry.id)}` + : `${ANTIGRAVITY_QUOTA_KEY_PREFIX}${category}`; + const label = category === "other" ? (entry.displayName ?? entry.id) : CATEGORY_LABELS[category]; + + const existing = grouped.get(key) ?? { + key, + label, + rank: CATEGORY_RANK[category], + percent: null, + count: 0, + }; + + grouped.set(key, { + ...existing, + // Worst remaining in the group: a family shares one bucket upstream, so + // the pessimistic reading is the one that reflects what is left. + percent: + entry.percent === null + ? existing.percent + : existing.percent === null + ? entry.percent + : Math.min(existing.percent, entry.percent), + resetAtMs: earlierResetAtMs(existing.resetAtMs, entry.resetAtMs), + count: existing.count + 1, + }); + }); + + return [...grouped.values()] + .filter((group) => group.count > 0) + .sort((a, b) => a.rank - b.rank) + .map((group) => ({ + key: group.key, + label: group.label, + percent: group.percent, + ...(group.resetAtMs === undefined ? {} : { resetAtMs: group.resetAtMs }), + windowSeconds: FIVE_HOUR_SECONDS, + })); +}; + +/** + * Keys and labels written by the previous grouping, kept only so cached rows + * still render while they age out. Nothing new is ever written in these shapes, + * and unlike the id list this replaced, a model missing from here is classified + * by shape rather than dropped. + */ +const LEGACY_GROUP_ALIASES: Record> = { + "provider:gemini3-pro": "gemini_pro", + "provider:gemini3-flash": "gemini_flash", + "provider:gemini-image": "gemini_image", + "provider:claude": "claude", + "antigravity_quota.gemini3_pro": "gemini_pro", + "antigravity_quota.gemini3_flash": "gemini_flash", + "antigravity_quota.gemini_image": "gemini_image", + "antigravity_quota.claude": "claude", +}; + +const resolveLegacyCategory = ( + item: QuotaItem, +): Exclude | null => { + const key = String(item.key ?? "").trim(); + if (key && LEGACY_GROUP_ALIASES[key]) return LEGACY_GROUP_ALIASES[key]; + const label = String(item.label ?? "").trim(); + return label && LEGACY_GROUP_ALIASES[label] ? LEGACY_GROUP_ALIASES[label] : null; +}; + +export const summarizeAntigravityQuotaItems = (items: QuotaItem[]): QuotaItem[] => { + // Items already carrying an antigravity key have been grouped upstream (or by + // the fetch layer) and must pass through untouched — re-grouping them by + // parsing model ids back out of their labels is what let a summary bucket be + // mistaken for a model row. + if (items.some((item) => String(item.key ?? "").startsWith(ANTIGRAVITY_QUOTA_KEY_PREFIX))) { + return items.filter((item) => String(item.key ?? "").startsWith(ANTIGRAVITY_QUOTA_KEY_PREFIX)); + } + + const legacyGrouped = new Map(); + const modelEntries: ModelEntry[] = []; + + items.forEach((item) => { + const percent = + typeof item.percent === "number" && Number.isFinite(item.percent) + ? clampPercent(item.percent) + : null; + + const legacy = resolveLegacyCategory(item); + if (legacy) { + const key = `${ANTIGRAVITY_QUOTA_KEY_PREFIX}${legacy}`; + const existing = legacyGrouped.get(key); + legacyGrouped.set(key, { + key, + label: CATEGORY_LABELS[legacy], + percent: + percent === null + ? (existing?.percent ?? null) + : existing?.percent == null + ? percent + : Math.min(existing.percent, percent), + ...(earlierResetAtMs(existing?.resetAtMs, item.resetAtMs) === undefined + ? {} + : { resetAtMs: earlierResetAtMs(existing?.resetAtMs, item.resetAtMs) }), + windowSeconds: FIVE_HOUR_SECONDS, + }); + return; + } + + const id = resolveModelIdFromQuotaItem(item); + if (!id || shouldSkipAntigravityModelId(id)) return; + modelEntries.push({ + id, + displayName: extractDisplayNameFromLabel(item.label), + percent, + ...(item.resetAtMs === undefined ? {} : { resetAtMs: item.resetAtMs }), + }); + }); + + const fromModels = groupModelEntries(modelEntries); + if (legacyGrouped.size === 0) return fromModels; + + const ordered = [...legacyGrouped.values()].sort( + (a, b) => + CATEGORY_RANK[categoryFromKey(a.key)] - CATEGORY_RANK[categoryFromKey(b.key)], + ); + return [...ordered, ...fromModels.filter((item) => !legacyGrouped.has(String(item.key)))]; +}; + +const categoryFromKey = (key: string | undefined): AntigravityQuotaCategory => { + const suffix = String(key ?? "").slice(ANTIGRAVITY_QUOTA_KEY_PREFIX.length); + return suffix in CATEGORY_RANK ? (suffix as AntigravityQuotaCategory) : "other"; +}; + +const MODEL_KEY_PREFIX = "model:"; + +const resolveModelIdFromQuotaItem = (item: QuotaItem): string | null => { + const key = typeof item.key === "string" ? item.key.trim() : ""; + if (key.startsWith(MODEL_KEY_PREFIX)) { + return key.slice(MODEL_KEY_PREFIX.length).trim() || null; + } + const label = String(item.label ?? "").trim(); + const bracketModelId = label.match(/\[([^\]]+)\]\s*$/)?.[1]?.trim(); + if (bracketModelId) return bracketModelId; + return key || label || null; +}; + +const extractDisplayNameFromLabel = (label: string): string | undefined => { + const trimmed = String(label ?? "").trim(); + const withoutId = trimmed.replace(/\s*\[[^\]]+\]\s*$/, "").trim(); + return withoutId || undefined; +}; + +export const filterAntigravityQuotaItems = (items: QuotaItem[]): QuotaItem[] => + summarizeAntigravityQuotaItems(items); + export const buildAntigravityItems = ( input: AntigravityFetchAvailableModelsPayload | AntigravityModelsPayload, -): QuotaItem[] => summarizeAntigravityQuotaItems(buildAntigravityModelItems(input)); +): QuotaItem[] => groupModelEntries(collectModelEntries(input)); export const buildAntigravityGroups = ( input: AntigravityFetchAvailableModelsPayload | AntigravityModelsPayload, @@ -330,6 +530,27 @@ export const buildAntigravityGroups = ( }; }); +/** + * Deprecated model ids the upstream reports alongside the model list, as + * `{ oldId: { newModelId } }`. The upstream drives its own retirements; nothing + * here needs a table of which model replaced which. + */ +export const parseAntigravityForwardingRules = (payload: unknown): Record => { + if (!isRecord(payload)) return {}; + const deprecated = payload.deprecatedModelIds ?? payload.deprecated_model_ids; + if (!isRecord(deprecated)) return {}; + + const rules: Record = {}; + Object.entries(deprecated).forEach(([oldId, info]) => { + const target = isRecord(info) + ? normalizeStringValue(info.newModelId ?? info.new_model_id) + : normalizeStringValue(info); + const source = normalizeStringValue(oldId); + if (source && target) rules[source] = target; + }); + return rules; +}; + export const parseAntigravityPayload = (payload: unknown): Record | null => { if (payload === undefined || payload === null) return null; if (typeof payload === "string") { diff --git a/features/quota-preview/quota-fetch.ts b/features/quota-preview/quota-fetch.ts index 01816d22..a6d5ebf7 100644 --- a/features/quota-preview/quota-fetch.ts +++ b/features/quota-preview/quota-fetch.ts @@ -1,6 +1,8 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from "@code-proxy/api-client"; import type { ApiCallResult, AuthFileItem } from "@code-proxy/api-client"; import { + ANTIGRAVITY_LOAD_CODE_ASSIST_URLS, + ANTIGRAVITY_QUOTA_SUMMARY_URLS, ANTIGRAVITY_QUOTA_URLS, ANTIGRAVITY_REQUEST_HEADERS, CLAUDE_REQUEST_HEADERS, @@ -21,6 +23,7 @@ import { XAI_BILLING_WEEKLY_URL, XAI_REQUEST_HEADERS, buildAntigravityItems, + buildAntigravitySummaryItems, buildClaudeItems, buildCodexItems, buildGeminiCliBuckets, @@ -130,11 +133,11 @@ export const consumeCodexResetCredit = async (file: AuthFileItem): Promise } }; -const resolveAntigravityProjectId = async (file: AuthFileItem): Promise => { +const resolveStoredAntigravityProjectId = async (file: AuthFileItem): Promise => { try { const text = await authFilesApi.downloadText(file.name); const trimmed = text.trim(); - if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID; + if (!trimmed) return null; const parsed = JSON.parse(trimmed) as Record; const top = normalizeStringValue(parsed.project_id ?? parsed.projectId); if (top) return top; @@ -149,9 +152,85 @@ const resolveAntigravityProjectId = async (file: AuthFileItem): Promise const webId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null; if (webId) return webId; } catch { - return DEFAULT_ANTIGRAVITY_PROJECT_ID; + return null; } - return DEFAULT_ANTIGRAVITY_PROJECT_ID; + return null; +}; + +/** + * Ask the upstream which project this account actually owns. + * + * Quota is reported per project, so querying under a project the account does + * not own reports that project's remaining fraction — an exhausted account can + * come back reading 100%. The shared fallback id is only reached when the + * account genuinely has no project of its own. + */ +const fetchAntigravityProjectId = async (authIndex: string): Promise => { + const body = JSON.stringify({ + metadata: { ideType: "ANTIGRAVITY", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }, + }); + for (const url of ANTIGRAVITY_LOAD_CODE_ASSIST_URLS) { + try { + const result = await apiCallApi.request({ + authIndex, + method: "POST", + url, + header: { ...ANTIGRAVITY_REQUEST_HEADERS }, + data: body, + }); + if (result.statusCode < 200 || result.statusCode >= 300) continue; + const parsed = parseAntigravityPayload(result.body ?? result.bodyText); + if (!parsed) continue; + const project = parsed.cloudaicompanionProject; + const id = isRecord(project) + ? normalizeStringValue(project.id) + : normalizeStringValue(project); + if (id) return id; + } catch { + continue; + } + } + return null; +}; + +const resolveAntigravityProjectId = async ( + file: AuthFileItem, + authIndex: string, +): Promise => { + const stored = await resolveStoredAntigravityProjectId(file); + if (stored) return stored; + const fetched = await fetchAntigravityProjectId(authIndex); + return fetched ?? DEFAULT_ANTIGRAVITY_PROJECT_ID; +}; + +/** + * POST the project-scoped body and, on 403, retry once without the project + * field. A project the account cannot read is rejected outright, and the + * projectless form is what the upstream falls back to internally. + */ +const requestAntigravityQuota = async ( + authIndex: string, + urls: string[], + projectId: string, +): Promise<{ result: ApiCallResult | null; payload: Record | null }> => { + let last: ApiCallResult | null = null; + for (const url of urls) { + for (const body of [JSON.stringify({ project: projectId }), "{}"]) { + const result = await apiCallApi.request({ + authIndex, + method: "POST", + url, + header: { ...ANTIGRAVITY_REQUEST_HEADERS }, + data: body, + }); + last = result; + if (result.statusCode >= 200 && result.statusCode < 300) { + return { result, payload: parseAntigravityPayload(result.body ?? result.bodyText) }; + } + if (result.statusCode !== 403) break; + } + } + return { result: last, payload: null }; }; const isClaudeOAuthLikeFile = (file: AuthFileItem): boolean => { @@ -186,28 +265,28 @@ export const fetchQuota = async ( const authIndex = resolveAuthIndex(file); if (type === "antigravity") { - const projectId = await resolveAntigravityProjectId(file); - const requestBody = JSON.stringify({ project: projectId }); - let last: ApiCallResult | null = null; - for (const url of ANTIGRAVITY_QUOTA_URLS) { - const result = await apiCallApi.request({ - authIndex, - method: "POST", - url, - header: { ...ANTIGRAVITY_REQUEST_HEADERS }, - data: requestBody, - }); - last = result; - if (result.statusCode >= 200 && result.statusCode < 300) { - const parsed = parseAntigravityPayload(result.body ?? result.bodyText); - const models = parsed?.models; - if (!models || !isRecord(models)) throw new Error("no_model_quota"); - return { - items: buildAntigravityItems(parsed), - }; - } + const projectId = await resolveAntigravityProjectId(file, authIndex); + + // The grouped summary is the authoritative view — it carries both the weekly + // and the 5h window and groups them the way the upstream does. The flat + // model list is the fallback: 5h only, grouped by us. + const summary = await requestAntigravityQuota( + authIndex, + ANTIGRAVITY_QUOTA_SUMMARY_URLS, + projectId, + ); + if (summary.payload) { + const items = buildAntigravitySummaryItems(summary.payload); + if (items.length > 0) return { items }; + } + + const models = await requestAntigravityQuota(authIndex, ANTIGRAVITY_QUOTA_URLS, projectId); + if (models.payload) { + if (!isRecord(models.payload.models)) throw new Error("no_model_quota"); + return { items: buildAntigravityItems(models.payload) }; } - if (last) throw new Error(getApiCallErrorMessage(last)); + if (models.result) throw new Error(getApiCallErrorMessage(models.result)); + if (summary.result) throw new Error(getApiCallErrorMessage(summary.result)); throw new Error("request_failed"); } diff --git a/features/quota-preview/quota-helpers.ts b/features/quota-preview/quota-helpers.ts index 9e0030d1..83dbd410 100644 --- a/features/quota-preview/quota-helpers.ts +++ b/features/quota-preview/quota-helpers.ts @@ -2,11 +2,18 @@ import type { AuthFileItem } from "@code-proxy/api-client"; export { type AntigravityModelsPayload } from "@features/quota-preview/quota-antigravity"; export { + ANTIGRAVITY_QUOTA_KEY_PREFIX, buildAntigravityGroups, buildAntigravityItems, + buildAntigravitySummaryItems, + categorizeAntigravityModel, filterAntigravityQuotaItems, + parseAntigravityForwardingRules, parseAntigravityPayload, + parseAntigravityWindowSeconds, shouldSkipAntigravityModelId, + summarizeAntigravityQuotaItems, + type AntigravityQuotaCategory, } from "@features/quota-preview/quota-antigravity"; export { type CodexUsagePayload } from "@features/quota-preview/quota-codex"; export { @@ -53,18 +60,57 @@ export { } from "@features/quota-preview/quota-normalizers"; export type { QuotaItem, QuotaState, QuotaStatus } from "@features/quota-preview/quota-types"; +/** + * The shared project the upstream accepts for accounts that have none of their + * own. It is a last resort, not a default: asking for quota under a project that + * is not yours reports that project's remaining fraction, which is how an + * exhausted account comes back reading 100%. + */ export const DEFAULT_ANTIGRAVITY_PROJECT_ID = "bamboo-precept-lgxtn"; -export const ANTIGRAVITY_QUOTA_URLS = [ - "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", - "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels", - "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", +/** + * Ordered sandbox-first: the sandbox host stays reachable while the production + * host is shedding load with 429s. + */ +const ANTIGRAVITY_HOSTS = [ + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://daily-cloudcode-pa.googleapis.com", + "https://cloudcode-pa.googleapis.com", ]; +/** + * The grouped view. The upstream reports one bucket per model family per window + * (weekly and 5h) and names them itself, so nothing here has to guess which + * models share a quota. + */ +export const ANTIGRAVITY_QUOTA_SUMMARY_URLS = ANTIGRAVITY_HOSTS.map( + (host) => `${host}/v1internal:retrieveUserQuotaSummary`, +); + +/** The flat view. Only carries the 5h window and leaves grouping to the caller. */ +export const ANTIGRAVITY_QUOTA_URLS = ANTIGRAVITY_HOSTS.map( + (host) => `${host}/v1internal:fetchAvailableModels`, +); + +export const ANTIGRAVITY_LOAD_CODE_ASSIST_URLS = ANTIGRAVITY_HOSTS.map( + (host) => `${host}/v1internal:loadCodeAssist`, +); + +/** + * The client version the upstream gates its answer on. An outdated version is + * served a reduced model set and a coarser quota view, so this must track the + * real Antigravity client rather than whatever version happened to be current + * when the header was first written. + * + * The literal `1.X.X` is not an unfilled placeholder — that is the string the + * Antigravity client itself sends. + */ +export const ANTIGRAVITY_CLIENT_VERSION = "4.3.0"; + export const ANTIGRAVITY_REQUEST_HEADERS = { Authorization: "Bearer $TOKEN$", "Content-Type": "application/json", - "User-Agent": "antigravity/1.11.5 windows/amd64", + "User-Agent": `vscode/1.X.X (Antigravity/${ANTIGRAVITY_CLIENT_VERSION})`, }; export const GEMINI_CLI_QUOTA_URL = diff --git a/features/request-log-viewer/RequestLogModelCell.tsx b/features/request-log-viewer/RequestLogModelCell.tsx new file mode 100644 index 00000000..c6d4a6ba --- /dev/null +++ b/features/request-log-viewer/RequestLogModelCell.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from "react-i18next"; +import { isDistinctModelIdentity } from "@code-proxy/domain"; +import { HoverTooltip, OverflowTooltip } from "@code-proxy/ui"; +import { ModelTag } from "@features/model-tags"; +import type { RequestLogsRow } from "./requestLogsShared"; + +function ModelHintDot({ + label, + value, + toneClass, +}: { + label: string; + value: string; + toneClass: string; +}) { + return ( + + + + ); +} + +/** + * Model column of the request log table. + * + * The hint dots only appear for a genuinely different model. An account alias that + * merely adds a routing prefix (`ollama/deepseek-v4-flash:0731` for upstream + * `deepseek-v4-flash:0731`) is the same model under two names, and announcing it as + * a "real model ID" was noise on every single row of an aliased provider. + */ +export function RequestLogModelCell({ row }: { row: RequestLogsRow }) { + const { t } = useTranslation(); + if (!row.model) { + return --; + } + + const label = row.displayModel || row.model; + return ( + + + + + {isDistinctModelIdentity(row.model, row.upstreamModel) ? ( + + ) : null} + {isDistinctModelIdentity(row.model, row.visionFallbackModel) ? ( + + ) : null} + + ); +} diff --git a/features/request-log-viewer/requestLogsShared.tsx b/features/request-log-viewer/requestLogsShared.tsx index 005594b5..0a1f5f57 100644 --- a/features/request-log-viewer/requestLogsShared.tsx +++ b/features/request-log-viewer/requestLogsShared.tsx @@ -16,7 +16,7 @@ import { SearchableCheckboxMultiSelect, Tabs, TabsList, TabsTrigger } from "@cod import type { SearchableCheckboxMultiSelectOption } from "@code-proxy/ui"; import { HoverTooltip, OverflowTooltip } from "@code-proxy/ui"; import { PaginationBar } from "@code-proxy/ui"; -import { ModelTag } from "@features/model-tags"; +import { RequestLogModelCell } from "./RequestLogModelCell"; export type TimeRange = 1 | 7 | 14 | 30; export type StatusFilterValue = "success" | "failed"; @@ -940,38 +940,7 @@ export function buildRequestLogsColumns( width: "w-44", headerClassName: CENTERED_REQUEST_LOG_HEADER_CLASS, cellClassName: "text-center", - render: (row) => - row.model ? ( - - - - - {row.upstreamModel && row.upstreamModel !== row.model ? ( - - - - ) : null} - {row.visionFallbackModel && row.visionFallbackModel !== row.model ? ( - - - - ) : null} - - ) : ( - -- - ), + render: (row) => , }, ); return identityColumn === "none" diff --git a/packages/api-client/src/endpoints/__tests__/providers-commandcode.test.ts b/packages/api-client/src/endpoints/__tests__/providers-commandcode.test.ts new file mode 100644 index 00000000..260a2fe7 --- /dev/null +++ b/packages/api-client/src/endpoints/__tests__/providers-commandcode.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const getMock = vi.fn(); +const postMock = vi.fn(); +const putMock = vi.fn(); +const patchMock = vi.fn(); +const deleteMock = vi.fn(); + +vi.mock("../../client/client", () => ({ + apiClient: { + get: getMock, + post: postMock, + put: putMock, + patch: patchMock, + delete: deleteMock, + }, +})); + +describe("providersApi Command Code", () => { + beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + putMock.mockReset(); + patchMock.mockReset(); + deleteMock.mockReset(); + }); + + test("normalizes configs and defaults the Provider API base URL", async () => { + const { providersApi } = await import( + "@code-proxy/api-client/endpoints/providers" + ); + getMock.mockResolvedValue({ + "commandcode-api-key": [ + { + name: "GOAT plan", + "api-key": "cc-key", + disabled: true, + prefix: "cc", + "proxy-id": "hk", + headers: { "X-Test": "yes" }, + models: [{ name: "gpt-5.6-terra", alias: "terra" }], + "excluded-models": ["*"], + "vision-fallback-model": "google/gemini-3.5-flash", + }, + ], + }); + + const configs = await providersApi.getCommandCodeConfigs(); + expect(configs).toHaveLength(1); + expect(configs[0]).toMatchObject({ + name: "GOAT plan", + apiKey: "cc-key", + disabled: true, + prefix: "cc", + baseUrl: "https://api.commandcode.ai/provider/v1", + proxyId: "hk", + excludedModels: ["*"], + visionFallbackModel: "google/gemini-3.5-flash", + }); + }); + + // The credits endpoint authenticates with the inference key, so a Command Code + // row must never carry a dashboard cookie the way Cline and Ollama rows do. + test("drops any auth cookie rather than round-tripping it", async () => { + const { providersApi } = await import( + "@code-proxy/api-client/endpoints/providers" + ); + getMock.mockResolvedValue({ + "commandcode-api-key": [ + { "api-key": "cc-key", "auth-cookie": "should-be-ignored" }, + ], + }); + + const configs = await providersApi.getCommandCodeConfigs(); + expect(configs[0]).not.toHaveProperty("authCookie"); + + await providersApi.saveCommandCodeConfigs([ + { + apiKey: "cc-key", + authCookie: "should-be-ignored", + } as Parameters[0][number], + ]); + expect(putMock).toHaveBeenCalledWith("/commandcode-api-key", [ + { "api-key": "cc-key" }, + ]); + }); + + test("queries usage with the credential alone", async () => { + const { providersApi } = await import( + "@code-proxy/api-client/endpoints/providers" + ); + postMock.mockResolvedValue({ usage: [] }); + + await providersApi.queryCommandCodeUsage({ "api-key": "cc-key", index: 0 }); + expect(postMock).toHaveBeenCalledWith("/commandcode-api-key/usage", { + "api-key": "cc-key", + index: 0, + }); + }); + + test("patches by index and keeps an unchanged key out of the payload", async () => { + const { providersApi } = await import( + "@code-proxy/api-client/endpoints/providers" + ); + + await providersApi.patchCommandCodeConfig(2, { + apiKey: " ", + name: "renamed", + } as Parameters[1]); + + expect(patchMock).toHaveBeenCalledWith("/commandcode-api-key", { + index: 2, + value: { name: "renamed" }, + }); + }); +}); diff --git a/packages/api-client/src/endpoints/helpers.ts b/packages/api-client/src/endpoints/helpers.ts index 6c4f67d4..26e1e04b 100644 --- a/packages/api-client/src/endpoints/helpers.ts +++ b/packages/api-client/src/endpoints/helpers.ts @@ -262,6 +262,42 @@ export const serializeOllamaCloudKey = ( return payload; }; +// Command Code carries no auth-cookie: its plan usage authenticates with the +// same API key used for inference, so there is nothing for the operator to paste +// from a browser and nothing here that expires. +export const serializeCommandCodeKey = ( + config: ProviderSimpleConfig, + options: SerializeProviderKeyOptions = {}, +) => { + const payload: Record = {}; + const id = normalizeString(config.id); + if (id) payload.id = id; + if (options.includeApiKey !== false) payload["api-key"] = config.apiKey; + if (config.disabled !== undefined) payload.disabled = config.disabled; + const name = normalizeString(config.name); + if (name) payload.name = name; + const prefix = normalizeString(config.prefix); + if (prefix) payload.prefix = prefix; + const baseUrl = normalizeString(config.baseUrl); + if (baseUrl) payload["base-url"] = baseUrl; + const proxyUrl = normalizeString(config.proxyUrl); + if (proxyUrl) payload["proxy-url"] = proxyUrl; + const proxyId = normalizeString(config.proxyId); + if (proxyId) payload["proxy-id"] = proxyId; + const headers = serializeHeaders(config.headers); + if (headers) payload.headers = headers; + const models = serializeModels(config.models); + if (models) payload.models = models; + const excludedModels = modelAccessExcludedModels(config.excludedModels); + if (excludedModels !== undefined) { + payload["excluded-models"] = excludedModels; + } + const visionFallbackModel = normalizeString(config.visionFallbackModel); + if (visionFallbackModel) + payload["vision-fallback-model"] = visionFallbackModel; + return payload; +}; + export const serializeGeminiKey = (config: ProviderSimpleConfig) => { const payload: Record = { "api-key": config.apiKey }; const id = normalizeString(config.id); diff --git a/packages/api-client/src/endpoints/providers.ts b/packages/api-client/src/endpoints/providers.ts index 5228c06f..a1d20e5a 100644 --- a/packages/api-client/src/endpoints/providers.ts +++ b/packages/api-client/src/endpoints/providers.ts @@ -17,6 +17,7 @@ import { serializeGeminiKey, serializeBedrockKey, serializeClineKey, + serializeCommandCodeKey, serializeOllamaCloudKey, serializeOpenCodeGoKey, serializeOpenAIProvider, @@ -43,6 +44,10 @@ const normalizeClineBaseUrl = (value: unknown): string | undefined => const normalizeOllamaCloudBaseUrl = (value: unknown): string => normalizeString(value)?.replace(/\/+$/g, "") || "https://ollama.com"; +const normalizeCommandCodeBaseUrl = (value: unknown): string => + normalizeString(value)?.replace(/\/+$/g, "") || + "https://api.commandcode.ai/provider/v1"; + const normalizeModelAccessExcludedModels = ( value: unknown, ): string[] | undefined => @@ -370,6 +375,77 @@ export const providersApi = { params: { "api-key": apiKey }, }), + async getCommandCodeConfigs(): Promise { + const data = await apiClient.get("/commandcode-api-key"); + const list = extractArrayPayload(data, "commandcode-api-key"); + return list + .map((item) => { + if (!isRecord(item)) return null; + if (isOauthBackedProviderRow(item)) return null; + const id = normalizeString(item.id) ?? undefined; + const apiKey = normalizeString(item["api-key"] ?? item.apiKey) ?? ""; + if (!apiKey) return null; + const name = normalizeString(item.name) ?? undefined; + const prefix = normalizeString(item.prefix) ?? undefined; + const baseUrl = normalizeCommandCodeBaseUrl( + item["base-url"] ?? item.baseUrl, + ); + const proxyUrl = + normalizeString(item["proxy-url"] ?? item.proxyUrl) ?? undefined; + const proxyId = + normalizeString(item["proxy-id"] ?? item.proxyId) ?? undefined; + const headers = normalizeHeaders(item.headers); + const models = normalizeModels(item.models); + const excludedModels = normalizeModelAccessExcludedModels( + item["excluded-models"] ?? item.excludedModels, + ); + const visionFallbackModel = + normalizeString( + item["vision-fallback-model"] ?? item.visionFallbackModel, + ) ?? undefined; + return { + ...(id ? { id } : {}), + apiKey, + ...(item.disabled === true ? { disabled: true } : {}), + ...(name ? { name } : {}), + ...(prefix ? { prefix } : {}), + baseUrl, + ...(proxyUrl ? { proxyUrl } : {}), + ...(proxyId ? { proxyId } : {}), + ...(headers ? { headers } : {}), + ...(models ? { models } : {}), + ...(excludedModels ? { excludedModels } : {}), + ...(visionFallbackModel ? { visionFallbackModel } : {}), + }; + }) + .filter(Boolean) as ProviderSimpleConfig[]; + }, + + saveCommandCodeConfigs: (configs: ProviderSimpleConfig[]) => + apiClient.put( + "/commandcode-api-key", + configs.map((item) => serializeCommandCodeKey(item)), + ), + + patchCommandCodeConfig: (index: number, config: ProviderSimpleConfig) => + apiClient.patch("/commandcode-api-key", { + index, + value: serializeCommandCodeKey(config, { + includeApiKey: Boolean(config.apiKey.trim()), + }), + }), + + patchCommandCodeExcludedModels: (index: number, excludedModels: string[]) => + apiClient.patch("/commandcode-api-key", { + index, + value: { "excluded-models": excludedModels }, + }), + + deleteCommandCodeConfig: (apiKey: string) => + apiClient.delete("/commandcode-api-key", undefined, { + params: { "api-key": apiKey }, + }), + queryOpenCodeGoUsage: (payload: { "workspace-id"?: string; "auth-cookie"?: string; @@ -406,6 +482,20 @@ export const providersApi = { payload, ), + // No auth-cookie: Command Code reports plan windows from the same API key that + // serves inference, so usage never needs a browser session. + queryCommandCodeUsage: (payload: { + "proxy-id"?: string; + "proxy-url"?: string; + name?: string; + "api-key"?: string; + index?: number; + }) => + apiClient.post( + "/commandcode-api-key/usage", + payload, + ), + async getClaudeConfigs(): Promise { const data = await apiClient.get("/claude-api-key"); const list = extractArrayPayload(data, "claude-api-key"); diff --git a/packages/api-client/src/endpoints/video-generation.ts b/packages/api-client/src/endpoints/video-generation.ts new file mode 100644 index 00000000..d975d2f7 --- /dev/null +++ b/packages/api-client/src/endpoints/video-generation.ts @@ -0,0 +1,97 @@ +import { apiClient } from "../client/client"; + +const VIDEO_GENERATION_TASK_POLL_TIMEOUT_MS = 10 * 1000; + +/** A selectable video model, as reported by the server. */ +export interface VideoGenerationModel { + id: string; + provider: string; + display_name?: string; + description?: string; + /** True when the model can animate a source image, not just a prompt. */ + supports_image_to_video: boolean; + max_duration_seconds?: number; + price_per_call?: number; + /** Credentials of the current tenant that can serve this model. */ + channels?: string[]; + /** + * False when the tenant has no credential for the model's provider. The page + * disables generation in that case: submitting would fail deep in the router + * with "auth_not_found: no auth available", which says nothing actionable. + */ + available?: boolean; +} + +export interface VideoGenerationModelsResponse { + models: VideoGenerationModel[]; + /** Every usable channel across providers, flattened. */ + channels?: string[]; +} + +export interface VideoGenerationTestRequest { + model: string; + prompt: string; + /** Source image for image-to-video: an https URL or a data URI. */ + image?: string; + duration?: number; + aspect_ratio?: string; + resolution?: string; +} + +/** + * The finished upstream payload. The console renders `video.url`; the rest is kept + * so the raw response stays inspectable. + */ +export interface VideoGenerationTestResponse { + status?: string; + model?: string; + video?: { + url?: string; + duration?: number; + }; + request_id?: string; +} + +export type VideoGenerationTestTaskStatus = "queued" | "running" | "succeeded" | "failed"; + +export interface VideoGenerationTestTaskStartResponse { + task_id: string; + status: VideoGenerationTestTaskStatus; + phase?: string; + elapsed_ms?: number; +} + +export interface VideoGenerationTestTaskResponse extends VideoGenerationTestTaskStartResponse { + result?: VideoGenerationTestResponse; + error?: { + status?: number; + body?: { + error?: { + message?: string; + type?: string; + upstream?: unknown; + }; + }; + }; +} + +export const videoGenerationApi = { + getModels: (): Promise => { + return apiClient.get("/video-generation/models"); + }, + + startTestTask: ( + payload: VideoGenerationTestRequest, + ): Promise => { + return apiClient.post("/video-generation/test", payload); + }, + + // A clip takes minutes upstream, so the console task absorbs that wait and this + // poll only asks the server for the task's current phase. + getTestTask: (taskId: string): Promise => { + return apiClient.get( + `/video-generation/test/${encodeURIComponent(taskId)}`, + { timeoutMs: VIDEO_GENERATION_TASK_POLL_TIMEOUT_MS }, + ); + }, +}; diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 78d13033..12481e5e 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -112,6 +112,8 @@ export { updateApi } from "./endpoints/update"; export type * from "./endpoints/update"; export { imageGenerationApi } from "./endpoints/image-generation"; export type * from "./endpoints/image-generation"; +export { videoGenerationApi } from "./endpoints/video-generation"; +export type * from "./endpoints/video-generation"; export { proxiesApi } from "./endpoints/proxies"; export type * from "./endpoints/proxies"; export { diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 13f4a145..d44bec16 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -6,6 +6,7 @@ export * from "./ccswitch/ccswitchImportSettings"; export * from "./auth-files/authFiles"; export * from "./auth-files/types"; export * from "./auth-files/zip"; +export * from "./models/modelIdentity"; export * from "./quota"; export * from "./usage"; export * from "./tenant-cache"; diff --git a/packages/domain/src/models/__tests__/modelIdentity.test.ts b/packages/domain/src/models/__tests__/modelIdentity.test.ts new file mode 100644 index 00000000..e75e4335 --- /dev/null +++ b/packages/domain/src/models/__tests__/modelIdentity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vitest"; +import { isDistinctModelIdentity, isSameModelIdentity } from "../modelIdentity"; + +describe("isSameModelIdentity", () => { + test.each([ + ["deepseek-v4-flash:0731", "deepseek-v4-flash:0731"], + ["ollama/deepseek-v4-flash:0731", "deepseek-v4-flash:0731"], + ["deepseek-v4-flash:0731", "ollama/deepseek-v4-flash:0731"], + ["cline-pass/deepseek-v4-flash", "deepseek-v4-flash"], + ["group/ollama/gpt-oss:20b", "gpt-oss:20b"], + ["Ollama/GPT-OSS:20B", "gpt-oss:20b"], + ])("treats %s and %s as one model", (requested, upstream) => { + expect(isSameModelIdentity(requested, upstream)).toBe(true); + }); + + test.each([ + ["fast", "claude-sonnet-4"], + ["gpt-oss:20b", "gpt-oss:120b"], + ["xdeepseek-v4-flash", "deepseek-v4-flash"], + ["deepseek-v4-flash", ""], + ["", "deepseek-v4-flash"], + ])("keeps %s and %s apart", (requested, upstream) => { + expect(isSameModelIdentity(requested, upstream)).toBe(false); + }); +}); + +describe("isDistinctModelIdentity", () => { + test("is false when either side is missing", () => { + expect(isDistinctModelIdentity("deepseek-v4-flash", "")).toBe(false); + expect(isDistinctModelIdentity(" ", "deepseek-v4-flash")).toBe(false); + }); + + test("is true only for a genuinely different upstream model", () => { + expect(isDistinctModelIdentity("ollama/gpt-oss:20b", "gpt-oss:20b")).toBe(false); + expect(isDistinctModelIdentity("fast", "claude-sonnet-4")).toBe(true); + }); +}); diff --git a/packages/domain/src/models/modelIdentity.ts b/packages/domain/src/models/modelIdentity.ts new file mode 100644 index 00000000..7c9c6bc9 --- /dev/null +++ b/packages/domain/src/models/modelIdentity.ts @@ -0,0 +1,27 @@ +/** + * Whether a requested model name and the model actually used upstream denote the + * same model. + * + * Provider aliases normally only add a routing segment — an Ollama Cloud account + * exposing `deepseek-v4-flash:0731` as `ollama/deepseek-v4-flash:0731` makes the + * request log carry the prefixed name and the upstream field the bare one. Those + * are one model under two names, so surfacing a "real model ID" hint for them is + * pure noise. Aliases that rename the model (`fast` -> `claude-sonnet-4`) stay + * different and remain worth showing. + * + * The backend stopped recording alias-only upstream names, but request logs are + * kept for months, so the UI normalizes historical rows the same way. + */ +export const isSameModelIdentity = (requested: string, upstream: string): boolean => { + const a = requested.trim().toLowerCase(); + const b = upstream.trim().toLowerCase(); + if (!a || !b) return false; + if (a === b) return true; + return a.endsWith(`/${b}`) || b.endsWith(`/${a}`); +}; + +/** Inverse of {@link isSameModelIdentity}, for "should we disclose this name?" checks. */ +export const isDistinctModelIdentity = (requested: string, upstream: string): boolean => + Boolean(requested.trim()) && + Boolean(upstream.trim()) && + !isSameModelIdentity(requested, upstream); diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 80eb599b..1114119a 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -185,7 +185,8 @@ "system_info": "Management Center Info", "monitor": "Monitor Center", "menuManagement": "Menu Management", - "content_moderation": "Content Moderation" + "content_moderation": "Content Moderation", + "videoGeneration": "Video Models" }, "dashboard": { "title": "Dashboard", @@ -484,6 +485,10 @@ "search_empty_desc": "Try a different keyword or clear the search box" }, "auth_files": { + "sort_label": "Sort accounts", + "sort_by_name": "By name", + "sort_quota_asc": "Least quota left first", + "sort_quota_desc": "Most quota left first", "title": "AI Accounts", "title_section": "AI Accounts", "description": "Manage OAuth logins and auth credentials for AI platforms, including usage, quotas, and identity fingerprints.", @@ -1019,11 +1024,7 @@ "missing_auth_index": "Auth file missing auth_index", "empty_models": "No quota data available", "refresh_button": "Refresh Quota", - "fetch_all": "Fetch All", - "gemini3_pro": "Gemini 3 Pro", - "gemini3_flash": "Gemini 3 Flash", - "gemini_image": "Gemini 3.1 Flash Image", - "claude": "Claude" + "fetch_all": "Fetch All" }, "claude_quota": { "title": "Claude Quota", @@ -2089,7 +2090,8 @@ "stale_route_description": "This frontend may not include the requested page. Hard refresh to load the latest version and try again.", "stale_route_shortcut": "Hard refresh: Cmd+Shift+R on macOS; Ctrl+Shift+R on Windows/Linux.", "stale_route_reload": "Reload page", - "nav_ip_access": "IP Access Control" + "nav_ip_access": "IP Access Control", + "nav_video_generation": "Video Models" }, "proxies": { "title": "Proxy Management", @@ -3811,6 +3813,8 @@ "cline_connection_hint": "Base URL is the OpenAI-compatible root; requests are sent to /chat/completions below it.", "cline_endpoint_title": "ClinePass endpoint", "ollama_cloud_endpoint_title": "Ollama Cloud endpoint", + "commandcode_endpoint_title": "Command Code endpoint", + "commandcode_endpoint_hint": "Base URL is the OpenAI-compatible root; requests are sent to /chat/completions below it. The Go plan has no API access and is refused here \u2014 GOAT and above work.", "cline_endpoint_hint": "Use the API root as Base URL; the relay appends /chat/completions when sending requests.", "opencode_go_fixed_endpoint_title": "Fixed OpenCode Go endpoints", "opencode_go_fixed_endpoint_hint": "Chat, Anthropic Messages, and model discovery use official OpenCode Go endpoints. You only need to provide your OpenCode key.", @@ -3819,6 +3823,8 @@ "opencode_go_usage_config_hint": "After saving, refresh usage from the provider card.", "cline_usage_title": "ClinePass usage", "ollama_cloud_usage_title": "Ollama Cloud usage", + "commandcode_usage_title": "Command Code usage", + "commandcode_usage_hint": "Plan windows are read with this API key, so no dashboard cookie is needed.", "dashboard_usage_config_hint": "Paste the dashboard Cookie header, then save and refresh usage from the provider card.", "dashboard_auth_cookie_placeholder": "Full Cookie header from the dashboard request", "opencode_go_workspace_id": "Workspace ID / dashboard URL", @@ -3889,6 +3895,8 @@ "cline_models_hint": "Models are loaded from the relay's ClinePass definitions. Checked models are allowed; unchecked models are written to the blocked list.", "ollama_cloud_models_title": "Ollama Cloud model access", "ollama_cloud_models_hint": "Models are loaded from the relay's Ollama Cloud definitions. Checked models are allowed; unchecked models are written to the blocked list.", + "commandcode_models_title": "Command Code model access", + "commandcode_models_hint": "Models are loaded from Command Code's public catalog. Checked models are allowed; unchecked models are written to the blocked list.", "cline_real_model_id": "ClinePass real model ID", "real_model_id": "Real model ID", "cline_public_model_name": "Public model name", @@ -3944,7 +3952,11 @@ "anthropic_processing_label": "Anthropic Processing", "anthropic_processing_hint": "When enabled, applies Anthropic-specific processing (cloaking, cache control, tool prefix). Disable for third-party Claude-compatible APIs (e.g. Kimi) to improve performance.", "claude_models_discovery_hint": "Fetches Anthropic-compatible GET /v1/models using this key (x-api-key + Bearer). Empty Base URL defaults to https://api.anthropic.com.", - "codex_models_discovery_hint": "Fetches OpenAI-compatible GET /v1/models using this key. Empty Base URL defaults to https://api.openai.com. For ChatGPT OAuth accounts, use Auth Files → Models → Refresh instead." + "codex_models_discovery_hint": "Fetches OpenAI-compatible GET /v1/models using this key. Empty Base URL defaults to https://api.openai.com. For ChatGPT OAuth accounts, use Auth Files → Models → Refresh instead.", + "credential_sort_label": "Sort credentials", + "credential_sort_config": "Configured order", + "credential_sort_remaining_asc": "Least quota left first", + "credential_sort_remaining_desc": "Most quota left first" }, "logs_page": { "error_log_files": "Error Log Files", @@ -5441,5 +5453,53 @@ "portal_no_logins": "No sign-ins in the last 30 days", "chain_title": "Forwarding chain for this request (rightmost is the hop talking to this service)", "chain_hint": "Every hop must be declared before resolution reaches the real client on the left. Currently resolving to: {{client}}. A loopback result means one hop is still undeclared." + }, + "video_generation": { + "title": "Video Models", + "description": "See how to call Grok Imagine text-to-video and image-to-video, and verify the pipeline from the test panel.", + "call_title": "How to call", + "call_description": "Use an API key from the API Keys page. Generation is asynchronous: submit, then poll the returned request_id.", + "text_to_video_title": "Text to video", + "text_to_video_desc": "Prompt only. The model renders a first frame and animates it — good for creating from scratch.", + "image_to_video_title": "Image to video", + "image_to_video_desc": "Supply a source image as the first frame and describe the motion with a prompt.", + "request_params_title": "Request parameters", + "response_schema_title": "Response schema", + "table_param": "Field", + "table_type": "Type", + "table_required": "Required", + "table_description": "Description", + "table_default": "default", + "status_endpoint_hint": "Poll GET {{path}}. Read video.url once status is done; failed / expired are terminal.", + "param_model_desc": "Video model id, for example grok-imagine-video-1.5.", + "param_prompt_desc": "Describes the scene and camera motion to generate.", + "param_image_prompt_desc": "Describes how the source image should move, e.g. camera push or element motion.", + "param_image_desc": "Source image as {\"url\": \"...\"}. A plain URL string or data URI is also accepted and normalized server-side.", + "param_duration_desc": "Clip length in seconds; the ceiling depends on the model.", + "param_aspect_ratio_desc": "Aspect ratio, such as 16:9, 9:16 or 1:1.", + "param_resolution_desc": "Resolution: 480p, 720p or 1080p.", + "response_request_id_desc": "Job id returned on submission, used for polling.", + "response_status_desc": "Job status: pending, done, failed or expired.", + "response_video_url_desc": "URL of the finished clip.", + "response_video_duration_desc": "Length of the generated clip in seconds.", + "test_button": "Test generation", + "test_title": "Test video generation", + "test_submit": "Generate", + "test_running": "Generating", + "test_running_hint": "Video generation usually takes 1-3 minutes; keep this page open", + "test_failed_generic": "Video generation failed", + "test_model_required": "Select a video model first", + "test_prompt_required": "Enter a prompt", + "test_image_required": "Image-to-video needs a source image URL", + "field_model": "Model", + "field_prompt": "Prompt", + "field_prompt_placeholder": "e.g. ocean waves at sunset, camera slowly pulling back", + "field_image_url": "Source image URL (image-to-video)", + "field_duration": "Duration (seconds, max {{max}})", + "field_aspect_ratio": "Aspect ratio", + "field_resolution": "Resolution", + "result_open_original": "Open the original clip in a new tab", + "no_channel_hint": "This tenant has no usable xAI account, so video generation is unavailable. Add and enable a Grok account under AI Accounts first.", + "unavailable_suffix": "no account" } } diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json index ae94b1a8..737ce7b3 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -879,7 +879,11 @@ "fork": "Сохранить оригинал", "fork_label": "Сохранить оригинал", "fork_hint": "Если включено, доступны и исходное имя модели, и псевдоним. Если выключено, наружу виден только псевдоним.", - "cycle_tokens_count": "Токены за цикл {{value}}" + "cycle_tokens_count": "Токены за цикл {{value}}", + "sort_label": "Сортировка аккаунтов", + "sort_by_name": "По имени", + "sort_quota_asc": "Сначала с наименьшим остатком", + "sort_quota_desc": "Сначала с наибольшим остатком" }, "antigravity_quota": { "title": "Квота Antigravity", @@ -891,11 +895,7 @@ "missing_auth_index": "В файле авторизации отсутствует auth_index", "empty_models": "Данные по квоте отсутствуют", "refresh_button": "Обновить квоту", - "fetch_all": "Получить все", - "gemini3_pro": "Gemini 3 Pro", - "gemini3_flash": "Gemini 3 Flash", - "gemini_image": "Gemini 3.1 Flash Image", - "claude": "Claude" + "fetch_all": "Получить все" }, "claude_quota": { "title": "Квота Claude", @@ -2377,7 +2377,11 @@ "opencode_go_usage_compact_five_hour": "5 часов", "opencode_go_usage_compact_session": "Скользящее", "opencode_go_usage_remaining_unknown": "Left --", - "opencode_go_usage_remaining_percent": "Left {{percent}}%" + "opencode_go_usage_remaining_percent": "Left {{percent}}%", + "credential_sort_label": "Сортировка учётных данных", + "credential_sort_config": "Порядок из конфигурации", + "credential_sort_remaining_asc": "Сначала с наименьшим остатком", + "credential_sort_remaining_desc": "Сначала с наибольшим остатком" }, "log_content": { "detail_label_request": "Request", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 12f82a5d..dd7163e3 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -187,7 +187,8 @@ "system_info": "中心信息", "monitor": "监控中心", "menuManagement": "菜单管理", - "content_moderation": "内容审核" + "content_moderation": "内容审核", + "videoGeneration": "视频模型" }, "dashboard": { "title": "仪表盘", @@ -1015,7 +1016,11 @@ "no_tags": "暂无标签", "hide_tag": "隐藏 {{tag}}", "restore_tag": "恢复 {{tag}}", - "remove_custom_tag": "移除 {{tag}}" + "remove_custom_tag": "移除 {{tag}}", + "sort_label": "账号排序", + "sort_by_name": "按名称", + "sort_quota_asc": "剩余额度少的在前", + "sort_quota_desc": "剩余额度多的在前" }, "antigravity_quota": { "title": "Antigravity 额度", @@ -1027,11 +1032,7 @@ "missing_auth_index": "认证文件缺少 auth_index", "empty_models": "暂无额度数据", "refresh_button": "刷新额度", - "fetch_all": "获取全部", - "gemini3_pro": "Gemini 3 Pro", - "gemini3_flash": "Gemini 3 Flash", - "gemini_image": "Gemini 3.1 Flash Image", - "claude": "Claude" + "fetch_all": "获取全部" }, "claude_quota": { "title": "Claude 额度", @@ -2106,7 +2107,8 @@ "stale_route_description": "当前前端可能没有这个页面。请硬刷新后重试,以加载最新版本。", "stale_route_shortcut": "硬刷新:macOS 按 Cmd+Shift+R;Windows/Linux 按 Ctrl+Shift+R。", "stale_route_reload": "重新加载页面", - "nav_ip_access": "IP 访问控制" + "nav_ip_access": "IP 访问控制", + "nav_video_generation": "视频模型" }, "proxies": { "title": "代理管理", @@ -2811,6 +2813,8 @@ "cline_connection_hint": "Base URL 填 OpenAI 兼容根地址;转发请求会在其后追加 /chat/completions。", "cline_endpoint_title": "ClinePass 端点", "ollama_cloud_endpoint_title": "Ollama Cloud 端点", + "commandcode_endpoint_title": "Command Code 端点", + "commandcode_endpoint_hint": "Base URL 使用 OpenAI 兼容的根地址;relay 发送请求时会自动追加 /chat/completions。Go 套餐不含 API 权限会被上游拒绝,GOAT 及以上可用。", "cline_endpoint_hint": "Base URL 使用 API 根地址;relay 发送请求时会自动追加 /chat/completions。", "opencode_go_fixed_endpoint_title": "固定 OpenCode Go 端点", "opencode_go_fixed_endpoint_hint": "Chat、Anthropic Messages 和模型发现都使用官方 OpenCode Go 端点。这里只需要填写你的 OpenCode Key。", @@ -2819,6 +2823,8 @@ "opencode_go_usage_config_hint": "保存后可在提供商卡片刷新用量。", "cline_usage_title": "ClinePass 用量", "ollama_cloud_usage_title": "Ollama Cloud 用量", + "commandcode_usage_title": "Command Code 用量", + "commandcode_usage_hint": "用量直接用该 API Key 查询,无需填写面板 Cookie。", "dashboard_usage_config_hint": "粘贴 Dashboard 请求里的完整 Cookie 头,保存后可在提供商卡片刷新用量。", "dashboard_auth_cookie_placeholder": "Dashboard 请求里的完整 Cookie 头", "opencode_go_workspace_id": "Workspace ID / 控制台 URL", @@ -2889,6 +2895,8 @@ "cline_models_hint": "模型从 relay 内置的 ClinePass 定义加载。勾选表示允许使用,取消勾选会写入屏蔽列表。", "ollama_cloud_models_title": "Ollama Cloud 模型权限", "ollama_cloud_models_hint": "模型从 relay 内置的 Ollama Cloud 定义加载。勾选表示允许使用,取消勾选会写入屏蔽列表。", + "commandcode_models_title": "Command Code 模型权限", + "commandcode_models_hint": "模型从 Command Code 公开目录加载。勾选表示允许使用,取消勾选会写入屏蔽列表。", "cline_real_model_id": "ClinePass 真实模型 ID", "real_model_id": "真实模型 ID", "cline_public_model_name": "对外模型名", @@ -2944,7 +2952,11 @@ "anthropic_processing_label": "Anthropic 专有处理", "anthropic_processing_hint": "开启时将执行 Anthropic 专有处理(伪装、缓存控制、工具前缀等)。如 Base URL 指向第三方 Claude 兼容 API(如 Kimi),建议关闭以提升性能。", "claude_models_discovery_hint": "使用当前密钥请求 Anthropic 兼容的 GET /v1/models(x-api-key + Bearer)。Base URL 为空时默认 https://api.anthropic.com。", - "codex_models_discovery_hint": "使用当前密钥请求 OpenAI 兼容的 GET /v1/models。Base URL 为空时默认 https://api.openai.com。ChatGPT OAuth 账号请在「认证文件 → 模型 → 刷新」拉取上游清单。" + "codex_models_discovery_hint": "使用当前密钥请求 OpenAI 兼容的 GET /v1/models。Base URL 为空时默认 https://api.openai.com。ChatGPT OAuth 账号请在「认证文件 → 模型 → 刷新」拉取上游清单。", + "credential_sort_label": "凭证排序", + "credential_sort_config": "默认顺序", + "credential_sort_remaining_asc": "剩余额度少的在前", + "credential_sort_remaining_desc": "剩余额度多的在前" }, "request_logs": { "title": "请求日志", @@ -5455,5 +5467,53 @@ "portal_no_logins": "近 30 天没有登录记录", "chain_title": "当前请求的转发链(右侧为直连本服务的一跳)", "chain_hint": "每一跳都要声明为可信代理,解析才能走到最左侧的真实客户端。当前解析结果:{{client}}。若结果落在回环地址,说明还有一跳没声明。" + }, + "video_generation": { + "title": "视频模型", + "description": "查看 Grok Imagine 的文生视频 / 图生视频调用方式,并通过测试面板验证生成链路。", + "call_title": "调用方式", + "call_description": "使用 API Keys 页面配置的 API Key 调用视频接口;生成是异步的,提交后拿 request_id 轮询结果。", + "text_to_video_title": "文生视频", + "text_to_video_desc": "只给提示词,模型先生成首帧再动起来,适合从零创作。", + "image_to_video_title": "图生视频", + "image_to_video_desc": "提供一张源图作为首帧,用提示词描述镜头与动作。", + "request_params_title": "请求参数", + "response_schema_title": "返回结构", + "table_param": "字段", + "table_type": "类型", + "table_required": "必填", + "table_description": "说明", + "table_default": "默认", + "status_endpoint_hint": "轮询接口:GET {{path}};status 为 done 时取 video.url,failed / expired 表示终止。", + "param_model_desc": "视频模型 ID,例如 grok-imagine-video-1.5。", + "param_prompt_desc": "描述要生成的画面与镜头运动。", + "param_image_prompt_desc": "描述源图要如何动起来,例如镜头推拉、元素运动。", + "param_image_desc": "源图,传 {\"url\": \"...\"} 对象;也接受直接传 URL 字符串或 data URI,服务端会归一化。", + "param_duration_desc": "视频时长(秒),上限取决于模型。", + "param_aspect_ratio_desc": "画面比例,如 16:9、9:16、1:1。", + "param_resolution_desc": "分辨率:480p、720p、1080p。", + "response_request_id_desc": "提交成功后返回的任务 ID,用于轮询。", + "response_status_desc": "任务状态:pending、done、failed、expired。", + "response_video_url_desc": "生成完成后的视频地址。", + "response_video_duration_desc": "实际生成的视频时长(秒)。", + "test_button": "测试生成", + "test_title": "测试视频生成", + "test_submit": "开始生成", + "test_running": "生成中", + "test_running_hint": "视频生成通常需要 1-3 分钟,请保持页面打开", + "test_failed_generic": "视频生成失败", + "test_model_required": "请先选择视频模型", + "test_prompt_required": "请填写提示词", + "test_image_required": "图生视频需要提供源图地址", + "field_model": "模型", + "field_prompt": "提示词", + "field_prompt_placeholder": "例如:日落时分的海浪,镜头缓慢拉远", + "field_image_url": "源图地址(图生视频)", + "field_duration": "时长(秒,最多 {{max}})", + "field_aspect_ratio": "画面比例", + "field_resolution": "分辨率", + "result_open_original": "在新标签页打开原始视频", + "no_channel_hint": "当前租户下没有可用的 xAI 账号,无法生成视频。请先在「AI 账号」里添加并启用一个 Grok 账号。", + "unavailable_suffix": "无可用账号" } } diff --git a/packages/ui/src/code/CodeBlock.tsx b/packages/ui/src/code/CodeBlock.tsx new file mode 100644 index 00000000..baefdd58 --- /dev/null +++ b/packages/ui/src/code/CodeBlock.tsx @@ -0,0 +1,63 @@ +import { useMemo, type ReactNode } from "react"; +import { highlightSnippet, TOKEN_CLASS, type SnippetLanguage } from "./highlightSnippet"; + +/** + * Dark, syntax-highlighted code block. + * + * Shared by the landing page and the media-model pages so a curl example reads the + * same everywhere. The tokenizer is the tiny hand-written one in highlightSnippet: + * pulling in react-syntax-highlighter for these fixed snippets would drag the + * 790KB vendor-markdown chunk into pages that need one code block. + */ +export function CodeBlock({ + code, + language = "shell", + label, + action, + className, +}: { + code: string; + language?: SnippetLanguage; + /** Small caption in the block header, e.g. "curl". */ + label?: ReactNode; + /** Optional trailing control, typically a copy button. */ + action?: ReactNode; + className?: string; +}) { + const highlighted = useMemo(() => highlightSnippet(code, language), [code, language]); + + return ( +
+ {label || action ? ( +
+ + {label} + + {action} +
+ ) : null} +
+        
+          {highlighted.map((tokens, lineIndex) => (
+            
+              {/* An empty line has no tokens; a zero-width space keeps its height. */}
+              {tokens.length === 0 ? "​" : null}
+              {tokens.map((token, tokenIndex) => (
+                
+                  {token.text}
+                
+              ))}
+            
+          ))}
+        
+      
+
+ ); +} diff --git a/pages/api-key-lookup/components/landing/highlightSnippet.ts b/packages/ui/src/code/highlightSnippet.ts similarity index 100% rename from pages/api-key-lookup/components/landing/highlightSnippet.ts rename to packages/ui/src/code/highlightSnippet.ts diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index a0236f4f..8f15f522 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -111,6 +111,9 @@ export { useLocalStorage } from "./hooks/useLocalStorage"; export { useResizeLayoutAnimation } from "./hooks/useResizeLayoutAnimation"; export { copyTextToClipboard } from "./utils/clipboard"; +export { CodeBlock } from "./code/CodeBlock"; +export { highlightSnippet, TOKEN_CLASS } from "./code/highlightSnippet"; +export type { CodeToken, SnippetLanguage, TokenKind } from "./code/highlightSnippet"; export { SecretRevealModal } from "./overlays/SecretRevealModal"; export { type ControlSize, diff --git a/packages/ui/src/navigation/menuIconMap.ts b/packages/ui/src/navigation/menuIconMap.ts index b547aee0..ae55af62 100644 --- a/packages/ui/src/navigation/menuIconMap.ts +++ b/packages/ui/src/navigation/menuIconMap.ts @@ -25,6 +25,7 @@ import { Store, UserRound, UsersRound, + Video, type LucideIcon, } from "lucide-react"; @@ -58,6 +59,7 @@ const ICON_MAP: Record = { store: Store, "user-round": UserRound, "users-round": UsersRound, + video: Video, }; export function resolveMenuIcon(name: string | undefined | null): LucideIcon { diff --git a/packages/ui/src/overlays/Tooltip.tsx b/packages/ui/src/overlays/Tooltip.tsx index 2944181b..584a4aee 100644 --- a/packages/ui/src/overlays/Tooltip.tsx +++ b/packages/ui/src/overlays/Tooltip.tsx @@ -172,8 +172,18 @@ function resolveTooltipPosition({ }; } +// scrollWidth/clientWidth are rounded to integers, so text that fits with a +// sub-pixel remainder (a 188.4px label in a 188px box) reports a 1px overflow +// while rendering in full, with no ellipsis. Without this tolerance the tooltip +// pops up repeating the text already on screen — which reads as the UI showing +// an "alias" that is identical to the name next to it. +const OVERFLOW_TOLERANCE_PX = 1; + function isElementOverflowing(element: HTMLElement) { - return element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight; + return ( + element.scrollWidth - element.clientWidth > OVERFLOW_TOLERANCE_PX || + element.scrollHeight - element.clientHeight > OVERFLOW_TOLERANCE_PX + ); } function hasOverflowingContent(element: HTMLElement) { diff --git a/packages/ui/src/overlays/__tests__/Tooltip.overflow.test.tsx b/packages/ui/src/overlays/__tests__/Tooltip.overflow.test.tsx new file mode 100644 index 00000000..92af01d9 --- /dev/null +++ b/packages/ui/src/overlays/__tests__/Tooltip.overflow.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test } from "vitest"; +import { OverflowTooltip } from "../Tooltip"; + +// jsdom has no layout, so overflow has to be dictated element by element. +const mockMetrics = (metrics: { scrollWidth: number; clientWidth: number }) => { + const originals = { + scrollWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollWidth"), + clientWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth"), + }; + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + get: () => metrics.scrollWidth, + }); + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get: () => metrics.clientWidth, + }); + return () => { + if (originals.scrollWidth) { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", originals.scrollWidth); + } + if (originals.clientWidth) { + Object.defineProperty(HTMLElement.prototype, "clientWidth", originals.clientWidth); + } + }; +}; + +describe("OverflowTooltip", () => { + let restore: (() => void) | null = null; + + afterEach(() => { + restore?.(); + restore = null; + }); + + test("stays closed for a 1px rounding overflow", async () => { + // A 188.4px label in a 188px box: scrollWidth rounds up to 189 while the text + // still renders in full. Opening here repeats the visible text for no reason. + restore = mockMetrics({ scrollWidth: 189, clientWidth: 188 }); + const user = userEvent.setup(); + + render( + + ollama/deepseek-v4-flash:0731 + , + ); + + await user.hover(screen.getByText("ollama/deepseek-v4-flash:0731")); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + test("still opens when the text is genuinely truncated", async () => { + restore = mockMetrics({ scrollWidth: 320, clientWidth: 188 }); + const user = userEvent.setup(); + + render( + + ollama/deepseek-v4-flash:0731 + , + ); + + await user.hover(screen.getByText("ollama/deepseek-v4-flash:0731")); + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "ollama/deepseek-v4-flash:0731", + ); + }); +}); diff --git a/pages/api-key-lookup/components/landing/LandingWorkflow.tsx b/pages/api-key-lookup/components/landing/LandingWorkflow.tsx index faf13d77..fd63edfd 100644 --- a/pages/api-key-lookup/components/landing/LandingWorkflow.tsx +++ b/pages/api-key-lookup/components/landing/LandingWorkflow.tsx @@ -1,8 +1,12 @@ import { useCallback, useMemo, useState } from "react"; import { motion } from "framer-motion"; import { Check, Copy } from "lucide-react"; -import { copyTextToClipboard } from "@code-proxy/ui"; -import { highlightSnippet, TOKEN_CLASS, type SnippetLanguage } from "./highlightSnippet"; +import { + copyTextToClipboard, + highlightSnippet, + TOKEN_CLASS, + type SnippetLanguage, +} from "@code-proxy/ui"; import { LANDING_EASE, useLandingFade } from "./landingMotion"; import { LandingSectionHead } from "./LandingSectionHead"; import type { LandingCopy } from "./landingCopy"; diff --git a/pages/auth-files/__tests__/AuthFilesPage.files-table.test.tsx b/pages/auth-files/__tests__/AuthFilesPage.files-table.test.tsx index bde59e38..13685863 100644 --- a/pages/auth-files/__tests__/AuthFilesPage.files-table.test.tsx +++ b/pages/auth-files/__tests__/AuthFilesPage.files-table.test.tsx @@ -3784,9 +3784,9 @@ describe("AuthFilesPage files table", () => { expect(await screen.findByText("antigravity.json")).toBeInTheDocument(); const cards = screen.getByTestId("auth-files-cards"); - expect(within(cards).getByText("Gemini 3 Pro")).toBeInTheDocument(); - expect(within(cards).getByText("Gemini 3 Flash")).toBeInTheDocument(); - expect(within(cards).getByText("Gemini 3.1 Flash Image")).toBeInTheDocument(); + expect(within(cards).getByText("Gemini Pro")).toBeInTheDocument(); + expect(within(cards).getByText("Gemini Flash")).toBeInTheDocument(); + expect(within(cards).getByText("Gemini Image")).toBeInTheDocument(); expect(within(cards).getByText("Claude")).toBeInTheDocument(); expect(within(cards).getByText("82%")).toBeInTheDocument(); expect(within(cards).getByText("77%")).toBeInTheDocument(); @@ -3873,7 +3873,7 @@ describe("AuthFilesPage files table", () => { expect(await screen.findByText("antigravity.json")).toBeInTheDocument(); const cards = screen.getByTestId("auth-files-cards"); - expect(within(cards).getByText("Gemini 3 Pro")).toBeInTheDocument(); + expect(within(cards).getByText("Gemini Pro")).toBeInTheDocument(); expect( within(cards).queryByText("Gemini 3.1 Pro (High) [gemini-3.1-pro-high]"), ).not.toBeInTheDocument(); @@ -3940,7 +3940,7 @@ describe("AuthFilesPage files table", () => { expect(await screen.findByText("antigravity.json")).toBeInTheDocument(); const cards = screen.getByTestId("auth-files-cards"); - expect(within(cards).getByText("Gemini 3 Pro")).toBeInTheDocument(); + expect(within(cards).getByText("Gemini Pro")).toBeInTheDocument(); expect(within(cards).getByText("91%")).toBeInTheDocument(); expect( within(cards).queryByText("Gemini 3.1 Pro (High) [gemini-3.1-pro-high]"), @@ -4009,7 +4009,7 @@ describe("AuthFilesPage files table", () => { expect(row).not.toBeNull(); const cell = within(row as HTMLElement); - expect(cell.getByText("Gemini 3 Pro")).toBeInTheDocument(); + expect(cell.getByText("Gemini Pro")).toBeInTheDocument(); expect( cell.queryByText("Gemini 3.1 Pro (Low) [gemini-3.1-pro-low]"), ).not.toBeInTheDocument(); @@ -4082,7 +4082,7 @@ describe("AuthFilesPage files table", () => { expect(row).not.toBeNull(); // Both metrics are readable in the row itself, so no overlay is needed. - const geminiChip = within(row as HTMLElement).getByText("Gemini 3 Pro"); + const geminiChip = within(row as HTMLElement).getByText("Gemini Pro"); expect(within(row as HTMLElement).getByText("Claude")).toBeInTheDocument(); fireEvent.mouseEnter(geminiChip); @@ -4156,7 +4156,7 @@ describe("AuthFilesPage files table", () => { const row = screen.getByText("antigravity.json").closest("tr"); expect(row).not.toBeNull(); expect(within(row as HTMLElement).queryByText("chat_20706")).not.toBeInTheDocument(); - expect(within(row as HTMLElement).getByText("Gemini 3 Pro")).toBeInTheDocument(); + expect(within(row as HTMLElement).getByText("Gemini Pro")).toBeInTheDocument(); expect( within(row as HTMLElement).queryByText("Gemini 3.1 Pro (High) [gemini-3.1-pro-high]"), ).not.toBeInTheDocument(); diff --git a/pages/auth-files/__tests__/authFilesQuotaSort.test.ts b/pages/auth-files/__tests__/authFilesQuotaSort.test.ts new file mode 100644 index 00000000..df754cc4 --- /dev/null +++ b/pages/auth-files/__tests__/authFilesQuotaSort.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vitest"; +import type { AuthFileItem } from "@code-proxy/api-client"; +import { + isAuthFilesSortMode, + resolveAuthFileQuotaRank, +} from "../hooks/useAuthFilesQuotaSort"; +import type { QuotaItem } from "@features/quota-preview/quota-types"; + +const file = (provider: string): AuthFileItem => + ({ name: `${provider}.json`, provider, type: provider }) as AuthFileItem; + +describe("resolveAuthFileQuotaRank", () => { + // The card shows one row per Codex window; the tightest of them decides the + // account's position, because that is the one that will refuse a request next. + test("ranks a codex account by its tightest visible window", () => { + const items: QuotaItem[] = [ + { key: "code_5h", label: "m_quota.code_5h", percent: 12 }, + { key: "code_week", label: "m_quota.code_weekly", percent: 80 }, + ]; + expect(resolveAuthFileQuotaRank(file("codex"), items)).toBe(12); + }); + + // Antigravity reports a weekly bucket too, but the card renders only the 5h + // one. Ranking by a number the operator cannot see reads as a broken sort. + test("ignores windows the antigravity card does not show", () => { + const items: QuotaItem[] = [ + { + key: "antigravity:gemini_5h", + label: "Gemini Models · 5h", + percent: 72, + windowSeconds: 5 * 60 * 60, + }, + { + key: "antigravity:gemini_weekly", + label: "Gemini Models · weekly", + percent: 3, + windowSeconds: 7 * 24 * 60 * 60, + }, + ]; + expect(resolveAuthFileQuotaRank(file("antigravity"), items)).toBe(72); + }); + + test("is unknown when no visible window carries a number", () => { + expect(resolveAuthFileQuotaRank(file("codex"), [])).toBeNull(); + expect( + resolveAuthFileQuotaRank(file("codex"), [ + { key: "code_5h", label: "m_quota.code_5h", percent: null }, + ]), + ).toBeNull(); + }); + + test("is unknown for a file with no quota provider", () => { + expect( + resolveAuthFileQuotaRank(file("unknown-provider"), [ + { key: "whatever", label: "whatever", percent: 40 }, + ]), + ).toBeNull(); + }); +}); + +describe("isAuthFilesSortMode", () => { + test("accepts known modes only", () => { + expect(isAuthFilesSortMode("name")).toBe(true); + expect(isAuthFilesSortMode("quota_asc")).toBe(true); + expect(isAuthFilesSortMode("quota_desc")).toBe(true); + expect(isAuthFilesSortMode("quota")).toBe(false); + expect(isAuthFilesSortMode(undefined)).toBe(false); + }); +}); + +describe("useAuthFilesSortMode", () => { + test("defaults to name order and shares the choice across instances", async () => { + const { renderHook, act } = await import("@testing-library/react"); + const { useAuthFilesSortMode, resetAuthFilesSortModeForTests } = await import( + "../hooks/useAuthFilesQuotaSort" + ); + localStorage.clear(); + resetAuthFilesSortModeForTests(); + + const first = renderHook(() => useAuthFilesSortMode()); + const second = renderHook(() => useAuthFilesSortMode()); + expect(first.result.current.mode).toBe("name"); + + // The list and the toolbar control read this independently; if they held + // separate state the control would move while the list stayed put. + act(() => first.result.current.setMode("quota_asc")); + expect(second.result.current.mode).toBe("quota_asc"); + + resetAuthFilesSortModeForTests(); + const reopened = renderHook(() => useAuthFilesSortMode()); + expect(reopened.result.current.mode).toBe("quota_asc"); + }); + + test("ignores an unrecognised stored value", async () => { + const { renderHook } = await import("@testing-library/react"); + const { useAuthFilesSortMode, resetAuthFilesSortModeForTests } = await import( + "../hooks/useAuthFilesQuotaSort" + ); + localStorage.setItem("auth-files:sort-mode", "by_feel"); + resetAuthFilesSortModeForTests(); + const { result } = renderHook(() => useAuthFilesSortMode()); + expect(result.current.mode).toBe("name"); + }); +}); diff --git a/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx b/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx new file mode 100644 index 00000000..566fc788 --- /dev/null +++ b/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from "react-i18next"; +import { Select } from "@code-proxy/ui"; +import { + AUTH_FILES_SORT_MODES, + isAuthFilesSortMode, + useAuthFilesSortLoading, + useAuthFilesSortMode, + type AuthFilesSortMode, +} from "../hooks/useAuthFilesQuotaSort"; + +const MODE_LABEL_KEYS: Record = { + name: "auth_files.sort_by_name", + quota_asc: "auth_files.sort_quota_asc", + quota_desc: "auth_files.sort_quota_desc", +}; + +/** + * Sort control for the AI accounts list. + * + * Reads the shared preference rather than taking it as a prop: both + * AuthFilesPage and AuthFilesFilesTab are frozen at their size baselines, and + * the list reads the same value independently to order accounts ahead of + * pagination. + * + * A Select rather than a dropdown menu, matching the column-count control it + * sits beside — same affordance for the same kind of choice, and no second + * popover implementation on this toolbar. + */ +export function AuthFilesQuotaSortMenu() { + const { t } = useTranslation(); + const { mode, setMode } = useAuthFilesSortMode(); + const loading = useAuthFilesSortLoading(); + + return ( +
+ setModel(value)} options={modelOptions} /> + + +