diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e54a6eb3..c9752e2d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,7 +31,7 @@ jobs: timeout-minutes: 15 env: PANEL_RELEASE: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} - PUBLIC_PANEL_BASE: ${{ vars.RELAY_PUBLIC_PANEL_BASE || 'https://relay.07230805.xyz' }} + PUBLIC_PANEL_BASE: ${{ secrets.RELAY_PUBLIC_PANEL_BASE || vars.RELAY_PUBLIC_PANEL_BASE }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 81b0ad82..1b43809c 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -34,7 +34,7 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL || vars.OPENAI_BASE_URL || 'https://relay.07230805.xyz/v1' }} + OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL || vars.OPENAI_BASE_URL }} OPENAI_MODEL: ${{ secrets.OPENAI_MODEL || vars.OPENAI_MODEL || 'grok-4.5' }} TRIAGE_ISSUE_NUMBER: ${{ github.event.inputs.issue_number || '' }} # Auto-comment on newly opened issues (not on every edit) unless bot:quiet. diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 948c2621..a84e3dc3 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -36,7 +36,7 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL || vars.OPENAI_BASE_URL || 'https://relay.07230805.xyz/v1' }} + OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL || vars.OPENAI_BASE_URL }} OPENAI_MODEL: ${{ secrets.OPENAI_MODEL || vars.OPENAI_MODEL || 'grok-4.5' }} REVIEW_PR_NUMBER: ${{ github.event.inputs.pr_number || '' }} steps: 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 ( + + + ); +} + +/** + * 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..6dd38d92 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", @@ -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", @@ -5441,5 +5449,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/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 12f82a5d..b05d809b 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": "仪表盘", @@ -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": "对外模型名", @@ -5455,5 +5463,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/api-key-lookup/components/landing/landingCopy.ts b/pages/api-key-lookup/components/landing/landingCopy.ts index 0e75ab45..8315a41d 100644 --- a/pages/api-key-lookup/components/landing/landingCopy.ts +++ b/pages/api-key-lookup/components/landing/landingCopy.ts @@ -58,7 +58,7 @@ export interface LandingCopy { /** 部署域名即 API 根地址;SSR / 测试环境下退回到线上默认值,保证文案永远可读。 */ export function resolveApiBaseUrl(): string { if (typeof window === "undefined" || !window.location?.origin) { - return "https://relay.07230805.xyz/v1"; + return "https://relay.example.com/v1"; } return `${window.location.origin}/v1`; } diff --git a/pages/image-generation/__tests__/ImageGenerationPage.test.tsx b/pages/image-generation/__tests__/ImageGenerationPage.test.tsx index 4d9f01a7..a745cb26 100644 --- a/pages/image-generation/__tests__/ImageGenerationPage.test.tsx +++ b/pages/image-generation/__tests__/ImageGenerationPage.test.tsx @@ -77,7 +77,11 @@ describe("ImageGenerationPage", () => { expect(screen.getByRole("tab", { name: "图生图" })).toBeInTheDocument(); expect(within(callCard as HTMLElement).getByText("POST")).toBeInTheDocument(); expect(within(callCard as HTMLElement).getByText("/v1/images/generations")).toBeInTheDocument(); - const textCurl = screen.getByText(/curl http:\/\/127\.0\.0\.1:8317\/v1\/images\/generations/); + // The snippet is syntax-highlighted, so its text is split across token spans: + // assert on the block's textContent rather than on a single text node. + const textCurl = document.querySelector("[data-code-block]") as HTMLElement; + expect(textCurl).not.toBeNull(); + expect(textCurl.textContent).toContain("curl http://127.0.0.1:8317/v1/images/generations"); expect( textCurl.compareDocumentPosition(screen.getByText("请求参数")) & Node.DOCUMENT_POSITION_FOLLOWING, @@ -98,8 +102,9 @@ describe("ImageGenerationPage", () => { expect(screen.getByText("size")).toBeInTheDocument(); expect(screen.getByText("quality")).toBeInTheDocument(); expect(screen.getByText("n")).toBeInTheDocument(); - expect(screen.getByText(/"size": "1024x1024"/)).toBeInTheDocument(); - expect(screen.getByText(/"quality": "high"/)).toBeInTheDocument(); + // Same reason as above: the highlighted snippet has no single node holding these. + expect(textCurl.textContent).toContain('"size": "1024x1024"'); + expect(textCurl.textContent).toContain('"quality": "high"'); expect(screen.queryByText("BaseURL")).not.toBeInTheDocument(); expect(screen.getByText(/Authorization: Bearer YOUR_API_KEY/)).toBeInTheDocument(); expect(within(callCard as HTMLElement).getByRole("button", { name: "测试生成" })).toBeEnabled(); diff --git a/pages/image-generation/components/ImageGenerationPageContent.tsx b/pages/image-generation/components/ImageGenerationPageContent.tsx index 985b0f16..5f63380a 100644 --- a/pages/image-generation/components/ImageGenerationPageContent.tsx +++ b/pages/image-generation/components/ImageGenerationPageContent.tsx @@ -3,6 +3,7 @@ import { ArrowUp, ChevronLeft, ChevronRight, CircleAlert, Plus, Trash2, X } from import { useTranslation } from "react-i18next"; import { imageGenerationApi } from "@code-proxy/api-client"; import { Button, COLUMN_WIDTH, surface } from "@code-proxy/ui"; +import { CodeBlock } from "@code-proxy/ui"; import { Card } from "@code-proxy/ui"; import { ImagePreviewOverlay } from "@code-proxy/ui"; import { Modal } from "@code-proxy/ui"; @@ -259,14 +260,11 @@ function EndpointCallDoc({ doc }: { doc: EndpointDoc }) { -
-
- curl -
-
-          {doc.curl}
-        
-
+ ); } diff --git a/pages/ip-access/ProtectionPolicyTab.tsx b/pages/ip-access/ProtectionPolicyTab.tsx index 65e0bc53..696a650f 100644 --- a/pages/ip-access/ProtectionPolicyTab.tsx +++ b/pages/ip-access/ProtectionPolicyTab.tsx @@ -162,7 +162,7 @@ export function ProtectionPolicyTab({ status, onPolicySaved }: ProtectionPolicyT setProxyDraft(""); } }} - placeholder="104.194.69.137 / 10.0.0.0/24" + placeholder="203.0.113.10 / 10.0.0.0/24" size="sm" className="font-mono" /> diff --git a/pages/ip-access/__tests__/AccessRulesTab.test.tsx b/pages/ip-access/__tests__/AccessRulesTab.test.tsx index 470bb1c7..1f529078 100644 --- a/pages/ip-access/__tests__/AccessRulesTab.test.tsx +++ b/pages/ip-access/__tests__/AccessRulesTab.test.tsx @@ -151,11 +151,11 @@ describe("AccessRulesTab", () => { test("protected addresses are listed so a refused ban is explainable", async () => { renderTab({ protectedEntries: [ - { cidr: "104.194.69.137/32", reason: "trusted_proxy" }, + { cidr: "203.0.113.10/32", reason: "trusted_proxy" }, { cidr: "10.0.0.5/32", reason: "local_address" }, ], }); - expect(await screen.findByText("104.194.69.137/32")).toBeInTheDocument(); + expect(await screen.findByText("203.0.113.10/32")).toBeInTheDocument(); expect(screen.getByText("10.0.0.5/32")).toBeInTheDocument(); }); }); diff --git a/pages/providers/components/ProviderKeyModal.tsx b/pages/providers/components/ProviderKeyModal.tsx index 319f06db..89116861 100644 --- a/pages/providers/components/ProviderKeyModal.tsx +++ b/pages/providers/components/ProviderKeyModal.tsx @@ -83,6 +83,7 @@ interface ProviderKeyModalProps { | "opencode-go" | "cline" | "ollama-cloud" + | "commandcode" | "vertex" | "bedrock"; keyDraft: ProviderKeyDraft; @@ -143,7 +144,9 @@ export function ProviderKeyModal({ const isOpenCodeGo = editKeyType === "opencode-go"; const isCline = editKeyType === "cline"; const isOllamaCloud = editKeyType === "ollama-cloud"; - const isModelAccessProvider = isOpenCodeGo || isCline || isOllamaCloud; + const isCommandCode = editKeyType === "commandcode"; + const isModelAccessProvider = + isOpenCodeGo || isCline || isOllamaCloud || isCommandCode; const supportsLiveDiscovery = editKeyType === "claude" || editKeyType === "codex"; const modelAccessProvider: ModelAccessProvider | null = isCline @@ -152,7 +155,9 @@ export function ProviderKeyModal({ ? "ollama-cloud" : isOpenCodeGo ? "opencode-go" - : null; + : isCommandCode + ? "commandcode" + : null; const showModelsTab = true; const [modelConfigs, setModelConfigs] = useState< diff --git a/pages/providers/components/ProviderKeyModelsTab.tsx b/pages/providers/components/ProviderKeyModelsTab.tsx index 8de9be27..e2456525 100644 --- a/pages/providers/components/ProviderKeyModelsTab.tsx +++ b/pages/providers/components/ProviderKeyModelsTab.tsx @@ -93,7 +93,9 @@ export function ProviderKeyModelsTab({ }: ProviderKeyModelsTabProps) { const { t } = useTranslation(); const isOllamaCloud = editKeyType === "ollama-cloud"; - const isModelAccessProvider = isOpenCodeGo || isCline || isOllamaCloud; + const isCommandCode = editKeyType === "commandcode"; + const isModelAccessProvider = + isOpenCodeGo || isCline || isOllamaCloud || isCommandCode; const supportsLiveDiscovery = (editKeyType === "claude" || editKeyType === "codex") && typeof discoverModels === "function" && @@ -103,12 +105,16 @@ export function ProviderKeyModelsTab({ ? t("providers.cline_models_title") : isOllamaCloud ? t("providers.ollama_cloud_models_title") - : t("providers.opencode_go_models_title"); + : isCommandCode + ? t("providers.commandcode_models_title") + : t("providers.opencode_go_models_title"); const modelAccessHint = isCline ? t("providers.cline_models_hint") : isOllamaCloud ? t("providers.ollama_cloud_models_hint") - : t("providers.opencode_go_models_hint"); + : isCommandCode + ? t("providers.commandcode_models_hint") + : t("providers.opencode_go_models_hint"); const modelEntryByName = useMemo(() => { const out = new Map(); for (const entry of keyDraft.modelEntries) { diff --git a/pages/providers/components/ProviderKeyRequestTab.tsx b/pages/providers/components/ProviderKeyRequestTab.tsx index 59896885..5fd53e9f 100644 --- a/pages/providers/components/ProviderKeyRequestTab.tsx +++ b/pages/providers/components/ProviderKeyRequestTab.tsx @@ -34,6 +34,7 @@ const OPENCODE_GO_CHAT_URL = "https://opencode.ai/zen/go/v1/chat/completions"; const OPENCODE_GO_MESSAGES_URL = "https://opencode.ai/zen/go/v1/messages"; const CLINE_BASE_URL = "https://api.cline.bot/api/v1"; const OLLAMA_CLOUD_BASE_URL = "https://ollama.com"; +const COMMANDCODE_BASE_URL = "https://api.commandcode.ai/provider/v1"; interface ProviderKeyRequestTabProps { keyDraft: ProviderKeyDraft; setKeyDraft: Dispatch>; @@ -68,6 +69,14 @@ export function ProviderKeyRequestTab({ const baseUrl = keyDraft.baseUrl.trim().replace(/\/+$/g, "") || OLLAMA_CLOUD_BASE_URL; return `${baseUrl}/api/chat`; }, [keyDraft.baseUrl]); + const isCommandCode = editKeyType === "commandcode"; + const commandCodeChatUrl = useMemo(() => { + const baseUrl = + keyDraft.baseUrl.trim().replace(/\/+$/g, "") || COMMANDCODE_BASE_URL; + return `${baseUrl}/chat/completions`; + }, [keyDraft.baseUrl]); + // Command Code reports usage from the API key itself, so it belongs to the + // usage-capable channels without belonging to the cookie-backed ones. const hasDashboardUsage = isOpenCodeGo || isCline || isOllamaCloud; const dashboardUsageTitle = isOpenCodeGo ? t("providers.opencode_go_usage_title") @@ -133,6 +142,30 @@ export function ProviderKeyRequestTab({ ) : null} + {isCommandCode ? ( + <> + +

+ {t("providers.commandcode_endpoint_title")} +

+

+ {commandCodeChatUrl} +

+

+ {t("providers.commandcode_endpoint_hint")} +

+
+ +

+ {t("providers.commandcode_usage_title")} +

+

+ {t("providers.commandcode_usage_hint")} +

+
+ + ) : null} + {hasDashboardUsage ? (

@@ -188,7 +221,7 @@ export function ProviderKeyRequestTab({ ) : null} - {isOpenCodeGo || isCline || editKeyType === "ollama-cloud" ? ( + {isOpenCodeGo || isCline || isOllamaCloud || isCommandCode ? (

{t("providers.opencode_go_vision_fallback_title")} diff --git a/pages/providers/components/ProviderModelChips.tsx b/pages/providers/components/ProviderModelChips.tsx index 808aaf9d..e00df146 100644 --- a/pages/providers/components/ProviderModelChips.tsx +++ b/pages/providers/components/ProviderModelChips.tsx @@ -1,5 +1,5 @@ import type { ProviderModel } from "@code-proxy/api-client"; -import { HoverTooltip } from "@code-proxy/ui"; +import { HoverTooltip, OverflowTooltip } from "@code-proxy/ui"; interface ProviderModelChipsProps { models: ProviderModel[]; @@ -31,7 +31,9 @@ export function ProviderModelChips({ {visible.map((model) => { const modelLabel = formatModelLabel(model, "→"); return ( - ")} placement="top" @@ -40,7 +42,7 @@ export function ProviderModelChips({ {modelLabel} - + ); })} {remaining > 0 ? ( diff --git a/pages/providers/components/ProviderTabsWithCounts.tsx b/pages/providers/components/ProviderTabsWithCounts.tsx index 40ee85f7..5ced8733 100644 --- a/pages/providers/components/ProviderTabsWithCounts.tsx +++ b/pages/providers/components/ProviderTabsWithCounts.tsx @@ -1,5 +1,5 @@ import { type ReactNode } from "react"; -import { Cloud } from "lucide-react"; +import { Cloud, SquareTerminal } from "lucide-react"; import iconGemini from "@code-proxy/assets/icons/gemini.svg"; import iconClaude from "@code-proxy/assets/icons/claude.svg"; import iconCodex from "@code-proxy/assets/icons/codex.svg"; @@ -19,6 +19,7 @@ export type ProviderTabId = | "opencode-go" | "cline" | "ollama-cloud" + | "commandcode" | "vertex" | "bedrock" | "openai" @@ -52,6 +53,9 @@ const TAB_META: Record = { }, cline: { icon: }, "ollama-cloud": { icon: }, + // Command Code ships no brand mark in this repo; a terminal glyph matches its + // CLI identity, the same way Bedrock falls back to a cloud glyph. + commandcode: { icon: }, vertex: { icon: }, bedrock: { icon: }, openai: { diff --git a/pages/providers/components/ProviderUsageTabContent.tsx b/pages/providers/components/ProviderUsageTabContent.tsx new file mode 100644 index 00000000..83a108b5 --- /dev/null +++ b/pages/providers/components/ProviderUsageTabContent.tsx @@ -0,0 +1,105 @@ +import type { ComponentProps } from "react"; +import type { ProviderSimpleConfig } from "@code-proxy/api-client"; +import { TabsContent } from "@code-proxy/ui"; +import { ProviderKeyListCard } from "../ProviderKeyListCard"; +import { + getEffectiveProviderModels, + type DiscoveredProviderModel, +} from "../provider-model-access"; +import { + getProviderUsageCacheKey, + hasProviderUsageQuery, + PROVIDER_USAGE_WINDOWS, + type ProviderUsageProvider, +} from "../provider-usage-config"; +import { + OpenCodeGoUsageCardSection, + OpenCodeGoUsageRefreshButton, + type OpenCodeGoUsageStore, +} from "./OpenCodeGoUsageCardSection"; + +type ListCardProps = ComponentProps; + +type ProviderUsageTabContentProps = { + provider: ProviderUsageProvider; + items: ProviderSimpleConfig[]; + loading: boolean; + /** OpenCode Go pins its own endpoint, so its cards hide the base URL row. */ + showBaseUrl?: boolean; + catalog: DiscoveredProviderModel[]; + usageStore: OpenCodeGoUsageStore; + getStats: ListCardProps["getStats"]; + getStatusBar: ListCardProps["getStatusBar"]; + onEdit: (index: number) => void; + onDelete: (index: number) => void; + onToggleEnabled: (index: number, enabled: boolean) => void; + onRefreshUsage: (item: ProviderSimpleConfig, index: number) => void; + selectedKeys: ListCardProps["selectedKeys"]; + onToggleSelected: ListCardProps["onToggleSelected"]; +}; + +/** + * The card list for a channel that reports plan usage. + * + * Every such channel renders the same card with the same usage section; only the + * provider id differs. Keeping one component means adding a channel costs a call + * site rather than another copy of this tree. + */ +export function ProviderUsageTabContent({ + provider, + items, + loading, + showBaseUrl = true, + catalog, + usageStore, + getStats, + getStatusBar, + onEdit, + onDelete, + onToggleEnabled, + onRefreshUsage, + selectedKeys, + onToggleSelected, +}: ProviderUsageTabContentProps) { + return ( + + item.disabled !== true} + getStats={getStats} + getStatusBar={getStatusBar} + getDisplayModels={(item) => + getEffectiveProviderModels(provider, item, catalog) + } + {...(showBaseUrl ? {} : { showBaseUrl: false })} + naturalHeight + showConnectionRows={false} + showModelMetric={false} + showExcludedModels={false} + renderExtra={(item, idx) => ( + + )} + renderMetricsExtra={(item, idx) => + hasProviderUsageQuery(provider, item) ? ( + onRefreshUsage(item, idx)} + /> + ) : null + } + selectedKeys={selectedKeys} + onToggleSelected={onToggleSelected} + /> + + ); +} diff --git a/pages/providers/components/ProvidersPageContent.tsx b/pages/providers/components/ProvidersPageContent.tsx index 5dcf28fb..f9dac251 100644 --- a/pages/providers/components/ProvidersPageContent.tsx +++ b/pages/providers/components/ProvidersPageContent.tsx @@ -31,8 +31,6 @@ import { ProviderKeyModal } from "./ProviderKeyModal"; import { useOpenAIProviderEditor } from "../hooks/useOpenAIProviderEditor"; import { ProviderKeyListCard } from "../ProviderKeyListCard"; import { - OpenCodeGoUsageRefreshButton, - OpenCodeGoUsageCardSection, createOpenCodeGoUsageStore, mergeOpenCodeGoUsage, type OpenCodeGoUsageCacheEntry, @@ -57,10 +55,22 @@ import { } from "../provider-import-export"; import { fetchModelAccessCatalog, - getEffectiveProviderModels, - type DiscoveredProviderModel, type ModelAccessProvider, } from "../provider-model-access"; +import { + EMPTY_MODEL_ACCESS_CATALOGS, + EMPTY_MODEL_ACCESS_CATALOG_LOADED, + getProviderUsageCacheKey, + hasProviderUsageQuery, + isModelAccessProvider, + migrateProviderUsageCache, + PROVIDER_LIST_CACHE_SLOTS, + type ModelAccessCatalogLoadedState, + type ModelAccessCatalogState, + type OpenCodeGoUsageState, + type ProviderUsageProvider, +} from "../provider-usage-config"; +import { ProviderUsageTabContent } from "./ProviderUsageTabContent"; import { ProvidersToolbar } from "./ProvidersToolbar"; import { ProviderTabsWithCounts } from "./ProviderTabsWithCounts"; import type { ProviderTabId } from "./ProviderTabsWithCounts"; @@ -76,6 +86,7 @@ const PROVIDER_TAB_VALUES: ProviderTab[] = [ "opencode-go", "cline", "ollama-cloud", + "commandcode", "vertex", "bedrock", "openai", @@ -119,96 +130,6 @@ const getProviderSelectionKey = ( .trim() .toLowerCase()}:${index}`; -const hasOpenCodeGoUsageQuery = (item: ProviderSimpleConfig) => - Boolean(item.workspaceId?.trim() && item.authCookie?.trim()); - -type ProviderUsageProvider = "opencode-go" | "cline" | "ollama-cloud"; - -const PROVIDER_USAGE_WINDOWS: Record = - { - "opencode-go": ["rolling", "weekly", "monthly"], - cline: ["five_hour", "weekly", "monthly"], - "ollama-cloud": ["rolling", "weekly"], - }; - -const getProviderUsageCacheKey = ( - provider: ProviderUsageProvider, - item: ProviderSimpleConfig, - index: number, -) => - [ - provider, - provider === "opencode-go" - ? item.workspaceId?.trim() || "no-workspace" - : "dashboard", - item.name?.trim() || item.apiKey?.trim() || `item-${index}`, - index, - ].join(":"); - -const migrateProviderUsageCache = ( - cached: OpenCodeGoUsageState, -): OpenCodeGoUsageState => { - const next = { ...cached }; - Object.entries(cached).forEach(([key, entry]) => { - if ( - key.startsWith("opencode-go:") || - key.startsWith("cline:") || - key.startsWith("ollama-cloud:") - ) { - return; - } - next[`opencode-go:${key}`] ??= entry; - }); - return next; -}; - -const hasProviderUsageQuery = ( - provider: ProviderUsageProvider, - item: ProviderSimpleConfig, -) => - provider === "opencode-go" - ? hasOpenCodeGoUsageQuery(item) - : Boolean(item.authCookie?.trim()); - -type OpenCodeGoUsageState = Record; -type ModelAccessCatalogState = Record< - ModelAccessProvider, - DiscoveredProviderModel[] ->; -type ModelAccessCatalogLoadedState = Record; - -const EMPTY_MODEL_ACCESS_CATALOGS: ModelAccessCatalogState = { - "opencode-go": [], - cline: [], - "ollama-cloud": [], -}; - -const EMPTY_MODEL_ACCESS_CATALOG_LOADED: ModelAccessCatalogLoadedState = { - "opencode-go": false, - cline: false, - "ollama-cloud": false, -}; - -const isModelAccessProvider = ( - tabId: ProviderTab, -): tabId is ModelAccessProvider => - tabId === "opencode-go" || tabId === "cline" || tabId === "ollama-cloud"; - -/** Provider list slots that seed from tenant-scoped localStorage. */ -const PROVIDER_LIST_CACHE_SLOTS: Record< - Exclude, - string -> = { - gemini: "gemini", - claude: "claude", - codex: "codex", - "opencode-go": "opencode-go", - cline: "cline", - "ollama-cloud": "ollama-cloud", - vertex: "vertex", - bedrock: "bedrock", - openai: "openai", -}; /** * Seed list state from the active tenant bucket only. @@ -312,7 +233,9 @@ export function ProvidersPage() { }) : provider === "cline" ? await providersApi.queryClineUsage(payload) - : await providersApi.queryOllamaCloudUsage(payload); + : provider === "ollama-cloud" + ? await providersApi.queryOllamaCloudUsage(payload) + : await providersApi.queryCommandCodeUsage(payload); const entry: OpenCodeGoUsageCacheEntry = { sourceId: result.workspace_id ?? provider, workspaceId: result.workspace_id, @@ -349,13 +272,6 @@ export function ProvidersPage() { [openCodeGoUsageStore, t], ); - const refreshOpenCodeGoUsage = useCallback( - async (item: ProviderSimpleConfig, index: number) => { - await refreshProviderUsage("opencode-go", item, index); - }, - [refreshProviderUsage], - ); - const [geminiKeys, setGeminiKeys] = useState(() => readCachedProviderList("gemini"), ); @@ -374,6 +290,9 @@ export function ProvidersPage() { const [ollamaCloudKeys, setOllamaCloudKeys] = useState< ProviderSimpleConfig[] >(() => readCachedProviderList("ollama-cloud")); + const [commandCodeKeys, setCommandCodeKeys] = useState< + ProviderSimpleConfig[] + >(() => readCachedProviderList("commandcode")); const [vertexKeys, setVertexKeys] = useState(() => readCachedProviderList("vertex"), ); @@ -390,7 +309,12 @@ export function ProvidersPage() { useEffect(() => { if (!canTestProviders) return; - if (tab !== "opencode-go" && tab !== "cline" && tab !== "ollama-cloud") + if ( + tab !== "opencode-go" && + tab !== "cline" && + tab !== "ollama-cloud" && + tab !== "commandcode" + ) return; if (loading) return; if (autoRefreshProviderUsageInFlightRef.current) return; @@ -401,7 +325,9 @@ export function ProvidersPage() { ? openCodeGoKeys : provider === "cline" ? clineKeys - : ollamaCloudKeys; + : provider === "ollama-cloud" + ? ollamaCloudKeys + : commandCodeKeys; if (items.length === 0) return; const staleKeys = items @@ -477,6 +403,7 @@ export function ProvidersPage() { | "opencode-go" | "cline" | "ollama-cloud" + | "commandcode" | "vertex" | "bedrock"; index: number; @@ -508,9 +435,18 @@ export function ProvidersPage() { ...ollamaCloudKeys.map((item, idx) => getProviderUsageCacheKey("ollama-cloud", item, idx), ), + ...commandCodeKeys.map((item, idx) => + getProviderUsageCacheKey("commandcode", item, idx), + ), ]), ); - }, [clineKeys, ollamaCloudKeys, openCodeGoKeys, openCodeGoUsageStore]); + }, [ + clineKeys, + commandCodeKeys, + ollamaCloudKeys, + openCodeGoKeys, + openCodeGoUsageStore, + ]); const loadProviderTab = useCallback(async (tabId: ProviderTab) => { switch (tabId) { @@ -562,6 +498,15 @@ export function ProvidersPage() { setCachedData("ollama-cloud", freshOl); break; } + case "commandcode": { + const cachedCc = + getCachedData("commandcode"); + if (cachedCc) setCommandCodeKeys(cachedCc); + const freshCc = await providersApi.getCommandCodeConfigs(); + setCommandCodeKeys(freshCc); + setCachedData("commandcode", freshCc); + break; + } case "vertex": { const cachedV = getCachedData("vertex"); if (cachedV) setVertexKeys(cachedV); @@ -771,6 +716,7 @@ export function ProvidersPage() { openCodeGoKeys, clineKeys, ollamaCloudKeys, + commandCodeKeys, vertexKeys, bedrockKeys, setGeminiKeys, @@ -779,6 +725,7 @@ export function ProvidersPage() { setOpenCodeGoKeys, setClineKeys, setOllamaCloudKeys, + setCommandCodeKeys, setVertexKeys, setBedrockKeys, refreshAll, @@ -836,6 +783,7 @@ export function ProvidersPage() { provider === "opencode-go" || provider === "cline" || provider === "ollama-cloud" || + provider === "commandcode" || provider === "vertex" || provider === "bedrock" ) { @@ -960,6 +908,8 @@ export function ProvidersPage() { return clineKeys; case "ollama-cloud": return ollamaCloudKeys; + case "commandcode": + return commandCodeKeys; case "vertex": return vertexKeys; case "bedrock": @@ -973,6 +923,7 @@ export function ProvidersPage() { claudeKeys, clineKeys, codexKeys, + commandCodeKeys, geminiKeys, ollamaCloudKeys, openCodeGoKeys, @@ -1025,6 +976,7 @@ export function ProvidersPage() { "opencode-go": openCodeGoKeys.length, cline: clineKeys.length, "ollama-cloud": ollamaCloudKeys.length, + commandcode: commandCodeKeys.length, vertex: vertexKeys.length, bedrock: bedrockKeys.length, openai: openaiProviders.length, @@ -1035,6 +987,7 @@ export function ProvidersPage() { claudeKeys, clineKeys, codexKeys, + commandCodeKeys, openCodeGoKeys, ollamaCloudKeys, vertexKeys, @@ -1073,6 +1026,11 @@ export function ProvidersPage() { items as ProviderSimpleConfig[], ); return; + case "commandcode": + await providersApi.saveCommandCodeConfigs( + items as ProviderSimpleConfig[], + ); + return; case "vertex": await providersApi.saveVertexConfigs(items as ProviderSimpleConfig[]); return; @@ -1283,6 +1241,11 @@ export function ProvidersPage() { label: "Ollama Cloud", count: tabCounts["ollama-cloud"], }, + { + id: "commandcode", + label: "Command Code", + count: tabCounts.commandcode, + }, { id: "vertex", label: "Vertex", count: tabCounts.vertex }, { id: "bedrock", label: "Bedrock", count: tabCounts.bedrock }, { @@ -1363,199 +1326,38 @@ export function ProvidersPage() { /> - - openKeyEditor("opencode-go", idx)} - onDelete={(idx) => - setConfirm({ - type: "deleteKey", - keyType: "opencode-go", - index: idx, - }) - } - onToggleEnabled={(idx, enabled) => - void toggleKeyEnabled("opencode-go", idx, enabled) - } - isItemEnabled={(item) => item.disabled !== true} - getStats={getSimpleStats} - getStatusBar={getSimpleStatusBar} - getDisplayModels={(item) => - getEffectiveProviderModels( - "opencode-go", - item, - modelAccessCatalogs["opencode-go"], - ) - } - showBaseUrl={false} - naturalHeight - showConnectionRows={false} - showModelMetric={false} - showExcludedModels={false} - renderExtra={(item, idx) => { - const queryReady = hasProviderUsageQuery("opencode-go", item); - const cacheKey = getProviderUsageCacheKey( - "opencode-go", - item, - idx, - ); - return ( - - ); - }} - renderMetricsExtra={(item, idx) => { - if (!hasProviderUsageQuery("opencode-go", item)) return null; - const cacheKey = getProviderUsageCacheKey( - "opencode-go", - item, - idx, - ); - return ( - void refreshOpenCodeGoUsage(item, idx)} - /> - ); - }} - selectedKeys={selectedExportKeySet} - onToggleSelected={toggleExportSelection} - /> - - - - openKeyEditor("cline", idx)} - onDelete={(idx) => - setConfirm({ type: "deleteKey", keyType: "cline", index: idx }) - } - onToggleEnabled={(idx, enabled) => - void toggleKeyEnabled("cline", idx, enabled) - } - isItemEnabled={(item) => item.disabled !== true} + {( + [ + { provider: "opencode-go", items: openCodeGoKeys, showBaseUrl: false }, + { provider: "cline", items: clineKeys }, + { provider: "ollama-cloud", items: ollamaCloudKeys }, + { provider: "commandcode", items: commandCodeKeys }, + ] as const + ).map(({ provider, items, ...rest }) => ( + - getEffectiveProviderModels( - "cline", - item, - modelAccessCatalogs.cline, - ) - } - naturalHeight - showConnectionRows={false} - showModelMetric={false} - showExcludedModels={false} - renderExtra={(item, idx) => { - const queryReady = hasProviderUsageQuery("cline", item); - const cacheKey = getProviderUsageCacheKey("cline", item, idx); - return ( - - ); - }} - renderMetricsExtra={(item, idx) => { - if (!hasProviderUsageQuery("cline", item)) return null; - const cacheKey = getProviderUsageCacheKey("cline", item, idx); - return ( - - void refreshProviderUsage("cline", item, idx) - } - /> - ); - }} - selectedKeys={selectedExportKeySet} - onToggleSelected={toggleExportSelection} - /> - - - - openKeyEditor("ollama-cloud", idx)} + onEdit={(idx) => openKeyEditor(provider, idx)} onDelete={(idx) => - setConfirm({ - type: "deleteKey", - keyType: "ollama-cloud", - index: idx, - }) + setConfirm({ type: "deleteKey", keyType: provider, index: idx }) } onToggleEnabled={(idx, enabled) => - void toggleKeyEnabled("ollama-cloud", idx, enabled) + void toggleKeyEnabled(provider, idx, enabled) } - isItemEnabled={(item) => item.disabled !== true} - getStats={getSimpleStats} - getStatusBar={getSimpleStatusBar} - getDisplayModels={(item) => - getEffectiveProviderModels( - "ollama-cloud", - item, - modelAccessCatalogs["ollama-cloud"], - ) + onRefreshUsage={(item, idx) => + void refreshProviderUsage(provider, item, idx) } - naturalHeight - showConnectionRows={false} - showModelMetric={false} - showExcludedModels={false} - renderExtra={(item, idx) => { - const queryReady = hasProviderUsageQuery("ollama-cloud", item); - const cacheKey = getProviderUsageCacheKey( - "ollama-cloud", - item, - idx, - ); - return ( - - ); - }} - renderMetricsExtra={(item, idx) => { - if (!hasProviderUsageQuery("ollama-cloud", item)) return null; - const cacheKey = getProviderUsageCacheKey( - "ollama-cloud", - item, - idx, - ); - return ( - - void refreshProviderUsage("ollama-cloud", item, idx) - } - /> - ); - }} selectedKeys={selectedExportKeySet} onToggleSelected={toggleExportSelection} + {...rest} /> - + ))} { + const original = { + scrollWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollWidth"), + clientWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth"), + }; + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + get: () => scrollWidth, + }); + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get: () => clientWidth, + }); + return () => { + if (original.scrollWidth) { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", original.scrollWidth); + } + if (original.clientWidth) { + Object.defineProperty(HTMLElement.prototype, "clientWidth", original.clientWidth); + } + }; +}; + describe("ProviderModelChips", () => { + let restoreOverflow: (() => void) | null = null; + + afterEach(() => { + restoreOverflow?.(); + restoreOverflow = null; + }); + test("keeps overflow models behind the final summary chip", async () => { const user = userEvent.setup(); const models = [ @@ -34,6 +72,7 @@ describe("ProviderModelChips", () => { test("shows the full model mapping for visible truncated chips", async () => { const user = userEvent.setup(); + restoreOverflow = mockChipOverflow({ scrollWidth: 400, clientWidth: 120 }); render( { "very-long-upstream-model-name => very-long-downstream-alias", ); }); + + test("stays quiet when the chip is fully visible", async () => { + const user = userEvent.setup(); + // A chip that fits has nothing to add: repeating its text as a tooltip is the + // "why is it showing me the alias again?" noise this component used to emit. + restoreOverflow = mockChipOverflow({ scrollWidth: 120, clientWidth: 120 }); + + render(); + + await user.hover(screen.getByText("short-model → short-alias")); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); }); diff --git a/pages/providers/hooks/useProviderKeyEditor.ts b/pages/providers/hooks/useProviderKeyEditor.ts index a49735cf..93c29297 100644 --- a/pages/providers/hooks/useProviderKeyEditor.ts +++ b/pages/providers/hooks/useProviderKeyEditor.ts @@ -36,6 +36,7 @@ export type ProviderKeyType = | "opencode-go" | "cline" | "ollama-cloud" + | "commandcode" | "vertex" | "bedrock"; @@ -46,6 +47,7 @@ interface UseProviderKeyEditorArgs { openCodeGoKeys: ProviderSimpleConfig[]; clineKeys: ProviderSimpleConfig[]; ollamaCloudKeys: ProviderSimpleConfig[]; + commandCodeKeys: ProviderSimpleConfig[]; vertexKeys: ProviderSimpleConfig[]; bedrockKeys: BedrockProviderConfig[]; setGeminiKeys: Dispatch>; @@ -54,6 +56,7 @@ interface UseProviderKeyEditorArgs { setOpenCodeGoKeys: Dispatch>; setClineKeys: Dispatch>; setOllamaCloudKeys: Dispatch>; + setCommandCodeKeys: Dispatch>; setVertexKeys: Dispatch>; setBedrockKeys: Dispatch>; refreshAll: () => Promise; @@ -68,6 +71,7 @@ export function useProviderKeyEditor({ openCodeGoKeys, clineKeys, ollamaCloudKeys, + commandCodeKeys, vertexKeys, bedrockKeys, setGeminiKeys, @@ -76,6 +80,7 @@ export function useProviderKeyEditor({ setOpenCodeGoKeys, setClineKeys, setOllamaCloudKeys, + setCommandCodeKeys, setVertexKeys, setBedrockKeys, refreshAll, @@ -106,14 +111,17 @@ export function useProviderKeyEditor({ ? clineKeys : type === "ollama-cloud" ? ollamaCloudKeys - : type === "vertex" - ? vertexKeys - : bedrockKeys, + : type === "commandcode" + ? commandCodeKeys + : type === "vertex" + ? vertexKeys + : bedrockKeys, [ bedrockKeys, claudeKeys, clineKeys, codexKeys, + commandCodeKeys, geminiKeys, ollamaCloudKeys, openCodeGoKeys, @@ -138,7 +146,12 @@ export function useProviderKeyEditor({ ? { ...draft, baseUrl: "https://api.cline.bot/api/v1" } : type === "ollama-cloud" && !draft.baseUrl.trim() ? { ...draft, baseUrl: "https://ollama.com" } - : draft, + : type === "commandcode" && !draft.baseUrl.trim() + ? { + ...draft, + baseUrl: "https://api.commandcode.ai/provider/v1", + } + : draft, ); setKeyDraftError(null); setEditKeyOpen(true); @@ -159,8 +172,10 @@ export function useProviderKeyEditor({ const isOpenCodeGo = editKeyType === "opencode-go"; const isCline = editKeyType === "cline"; const isOllamaCloud = editKeyType === "ollama-cloud"; + const isCommandCode = editKeyType === "commandcode"; const canKeepExistingApiKey = - editKeyIndex !== null && (isOpenCodeGo || isCline || isOllamaCloud); + editKeyIndex !== null && + (isOpenCodeGo || isCline || isOllamaCloud || isCommandCode); if (editKeyType === "bedrock") { if (keyDraft.authMode === "api-key" && !apiKey) { setKeyDraftError(t("providers.api_key_error")); @@ -188,7 +203,9 @@ export function useProviderKeyEditor({ ? "cline" : isOllamaCloud ? "ollama-cloud" - : null; + : isCommandCode + ? "commandcode" + : null; const disableAllModelAccess = Boolean( modelAccessProvider && hasDisableAllModelsRule(rawExcludedModels), ); @@ -350,6 +367,14 @@ export function useProviderKeyEditor({ await providersApi.patchOllamaCloudConfig(index, value); } setOllamaCloudKeys(next); + } else if (type === "commandcode") { + const next = apply(commandCodeKeys); + if (index === null) { + await providersApi.saveCommandCodeConfigs(next); + } else { + await providersApi.patchCommandCodeConfig(index, value); + } + setCommandCodeKeys(next); } else if (type === "vertex") { const next = apply(vertexKeys); await providersApi.saveVertexConfigs(next); @@ -434,6 +459,11 @@ export function useProviderKeyEditor({ setOllamaCloudKeys((prev) => prev.filter((_, itemIndex) => itemIndex !== index), ); + } else if (type === "commandcode") { + await providersApi.deleteCommandCodeConfig(entry.apiKey); + setCommandCodeKeys((prev) => + prev.filter((_, itemIndex) => itemIndex !== index), + ); } else if (type === "vertex") { await providersApi.deleteVertexConfig(entry.apiKey); setVertexKeys((prev) => @@ -479,6 +509,7 @@ export function useProviderKeyEditor({ | "opencode-go" | "cline" | "ollama-cloud" + | "commandcode" | "bedrock", index: number, enabled: boolean, @@ -496,7 +527,9 @@ export function useProviderKeyEditor({ ? clineKeys : type === "ollama-cloud" ? ollamaCloudKeys - : bedrockKeys; + : type === "commandcode" + ? commandCodeKeys + : bedrockKeys; const current = list[index]; if (!current) return; const prev = list; @@ -547,6 +580,12 @@ export function useProviderKeyEditor({ apiKey: "", disabled: !enabled, }); + } else if (type === "commandcode") { + setCommandCodeKeys(nextList); + await providersApi.patchCommandCodeConfig(index, { + apiKey: "", + disabled: !enabled, + }); } else { setBedrockKeys(nextList as BedrockProviderConfig[]); await providersApi.saveBedrockConfigs( @@ -567,6 +606,7 @@ export function useProviderKeyEditor({ else if (type === "opencode-go") setOpenCodeGoKeys(prev); else if (type === "cline") setClineKeys(prev); else if (type === "ollama-cloud") setOllamaCloudKeys(prev); + else if (type === "commandcode") setCommandCodeKeys(prev); else setBedrockKeys(prev as BedrockProviderConfig[]); notify({ type: "error", diff --git a/pages/providers/provider-import-export.ts b/pages/providers/provider-import-export.ts index 80ddee5a..8d432c7e 100644 --- a/pages/providers/provider-import-export.ts +++ b/pages/providers/provider-import-export.ts @@ -14,6 +14,7 @@ import { normalizeString, serializeBedrockKey, serializeClineKey, + serializeCommandCodeKey, serializeGeminiKey, serializeOllamaCloudKey, serializeOpenAIProvider, @@ -29,6 +30,7 @@ export type ProviderImportKind = | "opencode-go" | "cline" | "ollama-cloud" + | "commandcode" | "vertex" | "bedrock" | "openai"; @@ -40,6 +42,7 @@ type ProviderItemsByKind = { "opencode-go": ProviderSimpleConfig[]; cline: ProviderSimpleConfig[]; "ollama-cloud": ProviderSimpleConfig[]; + commandcode: ProviderSimpleConfig[]; vertex: ProviderSimpleConfig[]; bedrock: BedrockProviderConfig[]; openai: OpenAIProvider[]; @@ -167,7 +170,10 @@ const normalizeSimpleItem = ( if (!apiKey) return { item: null, duplicateCount: 0 }; const headers = sortRecord(normalizeHeaders(value.headers)); const hasDynamicModelAccess = - kind === "opencode-go" || kind === "cline" || kind === "ollama-cloud"; + kind === "opencode-go" || + kind === "cline" || + kind === "ollama-cloud" || + kind === "commandcode"; const { models, duplicateCount } = hasDynamicModelAccess ? { models: undefined, duplicateCount: 0 } : normalizeModelList(value.models); @@ -176,7 +182,11 @@ const normalizeSimpleItem = ( : sortExcludedModels(value["excluded-models"] ?? value.excludedModels); const baseUrl = normalizeString(value["base-url"] ?? value.baseUrl) ?? - (kind === "ollama-cloud" ? "https://ollama.com" : undefined); + (kind === "ollama-cloud" + ? "https://ollama.com" + : kind === "commandcode" + ? "https://api.commandcode.ai/provider/v1" + : undefined); return { item: { @@ -194,7 +204,7 @@ const normalizeSimpleItem = ( ...(headers ? { headers } : {}), ...(models ? { models } : {}), ...(excludedModels ? { excludedModels } : {}), - ...((kind === "opencode-go" || kind === "cline" || kind === "ollama-cloud") && + ...(hasDynamicModelAccess && normalizeString(value["vision-fallback-model"] ?? value.visionFallbackModel) ? { visionFallbackModel: normalizeString( @@ -382,6 +392,8 @@ const serializeItem = (kind: ProviderImportKind, item: CanonicalProviderItem) => return serializeProviderKey(item as ProviderSimpleConfig); case "ollama-cloud": return serializeOllamaCloudKey(item as ProviderSimpleConfig); + case "commandcode": + return serializeCommandCodeKey(item as ProviderSimpleConfig); case "opencode-go": return serializeOpenCodeGoKey(item as ProviderSimpleConfig); case "bedrock": diff --git a/pages/providers/provider-model-access.ts b/pages/providers/provider-model-access.ts index 240a693e..7e85fda8 100644 --- a/pages/providers/provider-model-access.ts +++ b/pages/providers/provider-model-access.ts @@ -10,7 +10,11 @@ import { normalizeDiscoveredModels, } from "./providers-helpers"; -export type ModelAccessProvider = "opencode-go" | "cline" | "ollama-cloud"; +export type ModelAccessProvider = + | "opencode-go" + | "cline" + | "ollama-cloud" + | "commandcode"; export type DiscoveredProviderModel = { id: string; owned_by?: string }; diff --git a/pages/providers/provider-usage-config.ts b/pages/providers/provider-usage-config.ts new file mode 100644 index 00000000..325204a4 --- /dev/null +++ b/pages/providers/provider-usage-config.ts @@ -0,0 +1,132 @@ +import type { ProviderSimpleConfig } from "@code-proxy/api-client"; +import type { OpenCodeGoUsageCacheEntry } from "./components/OpenCodeGoUsageCardSection"; +import type { + DiscoveredProviderModel, + ModelAccessProvider, +} from "./provider-model-access"; +import type { ProviderTabId } from "./components/ProviderTabsWithCounts"; + +/** + * Which channels report plan usage, how their windows are labelled, and what each + * one needs before a usage query is worth sending. + * + * Split out of ProvidersPageContent so adding a channel does not grow a file that + * is already frozen at its size baseline. + */ + +export type ProviderUsageProvider = + | "opencode-go" + | "cline" + | "ollama-cloud" + | "commandcode"; + +export type OpenCodeGoUsageState = Record; + +export const PROVIDER_USAGE_WINDOWS: Record< + ProviderUsageProvider, + readonly string[] +> = { + "opencode-go": ["rolling", "weekly", "monthly"], + cline: ["five_hour", "weekly", "monthly"], + "ollama-cloud": ["rolling", "weekly"], + commandcode: ["five_hour", "weekly"], +}; + +const USAGE_CACHE_SCOPE: Record = { + "opencode-go": "workspace", + cline: "dashboard", + "ollama-cloud": "dashboard", + // Keyed by credential rather than by browser session: Command Code reports + // usage from the API key itself, so there is no dashboard session to scope to. + commandcode: "apikey", +}; + +export const hasOpenCodeGoUsageQuery = (item: ProviderSimpleConfig) => + Boolean(item.workspaceId?.trim() && item.authCookie?.trim()); + +export const getProviderUsageCacheKey = ( + provider: ProviderUsageProvider, + item: ProviderSimpleConfig, + index: number, +) => + [ + provider, + provider === "opencode-go" + ? item.workspaceId?.trim() || "no-workspace" + : USAGE_CACHE_SCOPE[provider], + item.name?.trim() || item.apiKey?.trim() || `item-${index}`, + index, + ].join(":"); + +export const migrateProviderUsageCache = ( + cached: OpenCodeGoUsageState, +): OpenCodeGoUsageState => { + const next = { ...cached }; + Object.entries(cached).forEach(([key, entry]) => { + if ( + key.startsWith("opencode-go:") || + key.startsWith("cline:") || + key.startsWith("ollama-cloud:") || + key.startsWith("commandcode:") + ) { + return; + } + next[`opencode-go:${key}`] ??= entry; + }); + return next; +}; + +export const hasProviderUsageQuery = ( + provider: ProviderUsageProvider, + item: ProviderSimpleConfig, +) => { + if (provider === "opencode-go") return hasOpenCodeGoUsageQuery(item); + // Command Code needs no dashboard cookie; the inference key also reads credits. + if (provider === "commandcode") return Boolean(item.apiKey?.trim()); + return Boolean(item.authCookie?.trim()); +}; + +export type ModelAccessCatalogState = Record< + ModelAccessProvider, + DiscoveredProviderModel[] +>; +export type ModelAccessCatalogLoadedState = Record; + +export const EMPTY_MODEL_ACCESS_CATALOGS: ModelAccessCatalogState = { + "opencode-go": [], + cline: [], + "ollama-cloud": [], + commandcode: [], +}; + +export const EMPTY_MODEL_ACCESS_CATALOG_LOADED: ModelAccessCatalogLoadedState = { + "opencode-go": false, + cline: false, + "ollama-cloud": false, + commandcode: false, +}; + +export const isModelAccessProvider = ( + tabId: ProviderTabId, +): tabId is ModelAccessProvider => + tabId === "opencode-go" || + tabId === "cline" || + tabId === "ollama-cloud" || + tabId === "commandcode"; + +/** Provider list slots that seed from tenant-scoped localStorage. */ +export const PROVIDER_LIST_CACHE_SLOTS: Record< + Exclude, + string +> = { + gemini: "gemini", + claude: "claude", + codex: "codex", + "opencode-go": "opencode-go", + cline: "cline", + "ollama-cloud": "ollama-cloud", + commandcode: "commandcode", + vertex: "vertex", + bedrock: "bedrock", + openai: "openai", +}; diff --git a/pages/registry.ts b/pages/registry.ts index c5d0664d..12edc0ab 100644 --- a/pages/registry.ts +++ b/pages/registry.ts @@ -19,6 +19,7 @@ import { systemRoute } from "./system/route"; import { proxiesRoute } from "./proxies/route"; import { identityFingerprintRoute } from "./identity-fingerprint/route"; import { imageGenerationRoute } from "./image-generation/route"; +import { videoGenerationRoute } from "./video-generation/route"; import { ccswitchImportSettingsRoute } from "./ccswitch-import-settings/route"; import { apiKeyLookupRoute } from "./api-key-lookup/route"; import { apiKeyUsageRoute } from "./api-key-usage/route"; @@ -74,6 +75,7 @@ export const pageRoutes: PageRoute[] = [ proxiesRoute, identityFingerprintRoute, imageGenerationRoute, + videoGenerationRoute, ccswitchImportSettingsRoute, apiKeyLookupRoute, apiKeyUsageRoute, diff --git a/pages/request-logs/__tests__/RequestLogsPage.test.tsx b/pages/request-logs/__tests__/RequestLogsPage.test.tsx index 3ff5ab65..e1635b4e 100644 --- a/pages/request-logs/__tests__/RequestLogsPage.test.tsx +++ b/pages/request-logs/__tests__/RequestLogsPage.test.tsx @@ -396,6 +396,34 @@ describe("RequestLogsPage", () => { expect(await screen.findByRole("tooltip")).toHaveTextContent("Real model ID real-model"); }); + test("hides the real-model marker when the upstream name is only the alias prefix", async () => { + await i18n.changeLanguage("en"); + + mocks.getUsageLogs.mockResolvedValue( + responseWithRows([ + buildUsageLogItem({ + id: 1, + // How an Ollama Cloud account alias reaches the log: same model, two names. + model: "ollama/deepseek-v4-flash:0731", + upstream_model: "deepseek-v4-flash:0731", + vision_fallback_model: "", + }), + ]), + ); + + render( + + + + + , + ); + + const table = await screen.findByRole("table", { name: "Request Logs Table" }); + expect(within(table).getByText("ollama/deepseek-v4-flash:0731")).toBeInTheDocument(); + expect(within(table).queryByLabelText("Real model ID")).not.toBeInTheDocument(); + }); + test("renders empty state with normalized empty filter arrays", async () => { await i18n.changeLanguage("en"); diff --git a/pages/video-generation/VideoGenerationPage.tsx b/pages/video-generation/VideoGenerationPage.tsx new file mode 100644 index 00000000..e8b5b818 --- /dev/null +++ b/pages/video-generation/VideoGenerationPage.tsx @@ -0,0 +1,5 @@ +import { VideoGenerationPageContent } from "./components/VideoGenerationPageContent"; + +export function VideoGenerationPage() { + return ; +} diff --git a/pages/video-generation/__tests__/VideoGenerationPage.test.tsx b/pages/video-generation/__tests__/VideoGenerationPage.test.tsx new file mode 100644 index 00000000..a3abbe93 --- /dev/null +++ b/pages/video-generation/__tests__/VideoGenerationPage.test.tsx @@ -0,0 +1,140 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import i18n from "@code-proxy/i18n"; +import { videoGenerationApi } from "@code-proxy/api-client"; +import { ThemeProvider, ToastProvider } from "@code-proxy/ui"; +import { VideoGenerationPage } from "../VideoGenerationPage"; + +const getModelsMock = () => videoGenerationApi.getModels as unknown as ReturnType; +const startTaskMock = () => videoGenerationApi.startTestTask as unknown as ReturnType; +const getTaskMock = () => videoGenerationApi.getTestTask as unknown as ReturnType; + +const videoModel = { + 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, +}; + +function renderPage() { + return render( + + + + + + + , + ); +} + +describe("VideoGenerationPage", () => { + beforeEach(async () => { + await i18n.changeLanguage("zh-CN"); + vi.restoreAllMocks(); + vi.spyOn(videoGenerationApi, "getModels"); + vi.spyOn(videoGenerationApi, "startTestTask"); + vi.spyOn(videoGenerationApi, "getTestTask"); + getModelsMock().mockResolvedValue({ models: [videoModel] }); + startTaskMock().mockResolvedValue({ task_id: "task-1", status: "queued" }); + getTaskMock().mockResolvedValue({ task_id: "task-1", status: "queued" }); + }); + + test("documents the two-step async call for text and image modes", async () => { + const user = userEvent.setup(); + renderPage(); + + expect(await screen.findByRole("heading", { name: "视频模型" })).toBeInTheDocument(); + + // The snippet is syntax-highlighted, so its text lives across token spans. + const codeBlock = document.querySelector("[data-code-block]") as HTMLElement; + expect(codeBlock.textContent).toContain("curl http://127.0.0.1:8317/v1/videos/generations"); + // The polling half is the part callers miss; it must be in the example. + expect(codeBlock.textContent).toContain("/v1/videos/$REQUEST_ID"); + + await user.click(screen.getByRole("tab", { name: "图生视频" })); + + await waitFor(() => { + const imageBlock = document.querySelector("[data-code-block]") as HTMLElement; + expect(imageBlock.textContent).toContain('"image": { "url"'); + }); + }); + + test("offers the catalog's video models and submits a generation task", async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(await screen.findByRole("button", { name: "测试生成" })); + + await waitFor(() => expect(screen.getByText("测试视频生成")).toBeInTheDocument()); + await user.type(screen.getByPlaceholderText(/日落时分的海浪/), "海浪"); + await user.click(screen.getByRole("button", { name: "开始生成" })); + + await waitFor(() => expect(startTaskMock()).toHaveBeenCalled()); + const payload = startTaskMock().mock.calls[0][0] as Record; + expect(payload.model).toBe("grok-imagine-video-1.5"); + expect(payload.prompt).toBe("海浪"); + expect(payload.duration).toBeGreaterThan(0); + }); + + test("refuses to submit without a prompt", async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(await screen.findByRole("button", { name: "测试生成" })); + await waitFor(() => expect(screen.getByText("测试视频生成")).toBeInTheDocument()); + await user.click(screen.getByRole("button", { name: "开始生成" })); + + expect(startTaskMock()).not.toHaveBeenCalled(); + // The toast renders both a visible node and a live-region copy for screen + // readers, so match on presence rather than uniqueness. + expect((await screen.findAllByText("请填写提示词")).length).toBeGreaterThan(0); + }); + + // Screenshot regression: with no xAI credential the page still offered a live + // button, and the request died deep in the router with "auth_not_found". + test("disables generation when the tenant has no credential for the model", async () => { + getModelsMock().mockResolvedValue({ + models: [{ ...videoModel, available: false, channels: [] }], + channels: [], + }); + renderPage(); + + await waitFor(() => + expect(screen.getByRole("button", { name: "测试生成" })).toBeDisabled(), + ); + expect(screen.getByText(/没有可用的 xAI 账号/)).toBeInTheDocument(); + }); + + test("keeps generation enabled when the server omits availability", async () => { + // An older server does not send the field; absence must not disable the page. + getModelsMock().mockResolvedValue({ models: [videoModel] }); + renderPage(); + + await waitFor(() => expect(screen.getByRole("button", { name: "测试生成" })).toBeEnabled()); + }); + + test("plays the clip once the task finishes", async () => { + const user = userEvent.setup(); + getTaskMock().mockResolvedValue({ + task_id: "task-1", + status: "succeeded", + result: { status: "done", video: { url: "https://vidgen.example/clip.mp4", duration: 6 } }, + }); + renderPage(); + + await user.click(await screen.findByRole("button", { name: "测试生成" })); + await waitFor(() => expect(screen.getByText("测试视频生成")).toBeInTheDocument()); + await user.type(screen.getByPlaceholderText(/日落时分的海浪/), "海浪"); + await user.click(screen.getByRole("button", { name: "开始生成" })); + + await waitFor(() => { + const video = document.querySelector("video"); + expect(video?.getAttribute("src")).toBe("https://vidgen.example/clip.mp4"); + }); + }); +}); diff --git a/pages/video-generation/components/VideoGenerationPageContent.tsx b/pages/video-generation/components/VideoGenerationPageContent.tsx new file mode 100644 index 00000000..a33e0037 --- /dev/null +++ b/pages/video-generation/components/VideoGenerationPageContent.tsx @@ -0,0 +1,443 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CircleAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + videoGenerationApi, + type VideoGenerationModel, + type VideoGenerationTestResponse, +} from "@code-proxy/api-client"; +import { + Button, + Card, + CodeBlock, + DataTable, + Modal, + Select, + Tabs, + TabsList, + TabsTrigger, + useToast, + type DataTableColumn, +} from "@code-proxy/ui"; +import { + VIDEO_ASPECT_RATIOS, + VIDEO_ENDPOINT_DOCS, + VIDEO_RESOLUTIONS, + VIDEO_STATUS_PATH, + type SpecRow, + type VideoEndpointDoc, +} from "./apiDocs"; + +const TASK_POLL_INTERVAL_MS = 2000; +const DEFAULT_DURATION = 6; + +type TestState = { + running: boolean; + phase: string; + result: VideoGenerationTestResponse | null; + error: string | null; +}; + +const emptyTestState: TestState = { running: false, phase: "", result: null, error: null }; + +export function VideoGenerationPageContent() { + const { t } = useTranslation(); + const { notify } = useToast(); + + const [models, setModels] = useState([]); + const [modelsError, setModelsError] = useState(null); + const [mode, setMode] = useState("text"); + const [testOpen, setTestOpen] = useState(false); + const [test, setTest] = useState(emptyTestState); + + const [model, setModel] = useState(""); + const [prompt, setPrompt] = useState(""); + const [imageUrl, setImageUrl] = useState(""); + const [duration, setDuration] = useState(DEFAULT_DURATION); + const [aspectRatio, setAspectRatio] = useState(VIDEO_ASPECT_RATIOS[0]); + const [resolution, setResolution] = useState(VIDEO_RESOLUTIONS[1]); + + const pollTimer = useRef(null); + + useEffect(() => { + let cancelled = false; + void videoGenerationApi + .getModels() + .then((response) => { + if (cancelled) return; + const items = response.models ?? []; + setModels(items); + setModel((current) => current || (items[0]?.id ?? "")); + }) + .catch((error: unknown) => { + if (cancelled) return; + setModelsError(error instanceof Error ? error.message : String(error)); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect( + () => () => { + if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); + }, + [], + ); + + const doc = useMemo( + () => VIDEO_ENDPOINT_DOCS.find((entry) => entry.mode === mode) ?? VIDEO_ENDPOINT_DOCS[0], + [mode], + ); + const selectedModel = useMemo( + () => models.find((entry) => entry.id === model), + [model, models], + ); + const maxDuration = selectedModel?.max_duration_seconds || 15; + // A model the tenant has no credential for cannot be generated with. Saying so + // here — instead of letting the request fail with "auth_not_found" — is the + // difference between an actionable message and a dead end. `available` is + // undefined on an older server, which must not disable a working page. + const modelAvailable = selectedModel?.available !== false; + const anyModelAvailable = models.some((entry) => entry.available !== false); + const canGenerate = models.length > 0 && modelAvailable; + + const pollTask = useCallback( + (taskId: string) => { + void videoGenerationApi + .getTestTask(taskId) + .then((task) => { + if (task.status === "succeeded") { + setTest({ running: false, phase: "", result: task.result ?? null, error: null }); + return; + } + if (task.status === "failed") { + const message = + task.error?.body?.error?.message ?? t("video_generation.test_failed_generic"); + setTest({ running: false, phase: "", result: null, error: message }); + return; + } + setTest((current) => ({ ...current, phase: task.phase ?? task.status })); + pollTimer.current = window.setTimeout(() => pollTask(taskId), TASK_POLL_INTERVAL_MS); + }) + .catch((error: unknown) => { + setTest({ + running: false, + phase: "", + result: null, + error: error instanceof Error ? error.message : String(error), + }); + }); + }, + [t], + ); + + const handleRunTest = useCallback(() => { + if (!model.trim()) { + notify({ type: "warning", message: t("video_generation.test_model_required") }); + return; + } + if (!prompt.trim()) { + notify({ type: "warning", message: t("video_generation.test_prompt_required") }); + return; + } + if (mode === "image" && !imageUrl.trim()) { + notify({ type: "warning", message: t("video_generation.test_image_required") }); + return; + } + + setTest({ running: true, phase: "queued", result: null, error: null }); + void videoGenerationApi + .startTestTask({ + model, + prompt, + duration, + aspect_ratio: aspectRatio, + resolution, + ...(mode === "image" && imageUrl.trim() ? { image: imageUrl.trim() } : {}), + }) + .then((task) => pollTask(task.task_id)) + .catch((error: unknown) => { + setTest({ + running: false, + phase: "", + result: null, + error: error instanceof Error ? error.message : String(error), + }); + }); + }, [aspectRatio, duration, imageUrl, mode, model, pollTask, prompt, resolution, notify, t]); + + const modelOptions = useMemo( + () => + models.map((entry) => { + const base = entry.display_name ? `${entry.display_name} · ${entry.id}` : entry.id; + return { + value: entry.id, + label: entry.available === false ? `${base} (${t("video_generation.unavailable_suffix")})` : base, + }; + }), + [models, t], + ); + + return ( +

+
+

+ {t("video_generation.title")} +

+

+ {t("video_generation.description")} +

+
+ + +
+
+

+ {t("video_generation.call_title")} +

+

+ {t("video_generation.call_description")} +

+
+ +
+ + {modelsError ? ( +

+ + {modelsError} +

+ ) : null} + + {!modelsError && models.length > 0 && !anyModelAvailable ? ( +

+ + {t("video_generation.no_channel_hint")} +

+ ) : null} + +
+ setMode(value as VideoEndpointDoc["mode"])}> + + {VIDEO_ENDPOINT_DOCS.map((entry) => ( + + {t(`video_generation.${entry.titleKey}`)} + + ))} + + +
+ +
+
+
+

+ {t(`video_generation.${doc.titleKey}`)} +

+

+ {t(`video_generation.${doc.descriptionKey}`)} +

+
+ + + {doc.method} + + {doc.path} + +
+
+ + + +

+ {t("video_generation.status_endpoint_hint", { path: VIDEO_STATUS_PATH })} +

+ + + +
+ + setTestOpen(false)} + title={t("video_generation.test_title")} + maxWidth="max-w-[720px]" + > +
+