Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/13107-volcengine-console-cookie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(dashboard):** expose the Volcano Ark console cookie on quota scraping and unwrap connect-error objects so the dashboard shows the upstream message
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Input, Modal } from "@/shared/components";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
import { extractErrorMessage } from "@/shared/utils/upstreamError";

/**
* VolcengineConnectModal — phone/SMS-code login for the Volcano Engine console.
Expand Down Expand Up @@ -66,6 +67,16 @@ function isTerminal(phase: SessionPhase | undefined): boolean {
return !!phase && TERMINAL_PHASES.includes(phase);
}

function extractModalError(data: unknown, fallback: string): string {
const record = data && typeof data === "object" ? (data as Record<string, unknown>) : null;
return (
extractErrorMessage(record?.error) ||
(typeof record?.error === "string" ? record.error : null) ||
(typeof record?.message === "string" ? record.message : null) ||
fallback
);
}

type VolcengineConnectModalProps = {
isOpen: boolean;
onClose: () => void;
Expand Down Expand Up @@ -218,7 +229,7 @@ export default function VolcengineConnectModal({
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data?.success || !data?.session) {
throw new Error(data?.error || "Failed to start Volcano login");
throw new Error(extractModalError(data, "Failed to start Volcano login"));
}
setSession(data.session);
setResendCountdown(
Expand Down Expand Up @@ -269,7 +280,7 @@ export default function VolcengineConnectModal({
void onConnected();
}
} else {
throw new Error(data?.error || "Failed to submit verification code");
throw new Error(extractModalError(data, "Failed to submit verification code"));
}
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to submit verification code");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,16 +379,13 @@ export default function EditConnectionModal({
quotaPerUnit: existingQuotaPerUnit,
glmOrganizationId: existingGlmOrganizationId,
glmProjectId: existingGlmProjectId,
// Console-session credentials are stripped from API responses
// (sanitizeProviderSpecificDataForResponse), so there is nothing to
// round-trip: start empty and let "blank keeps the stored value" hold —
// the quota-scraping assign skips empty fields and the PUT merge
// preserves keys the payload does not carry.
// Console-session credentials stripped in responses; blank preserves stored values.
ollamaCloudUsageCookie: "",
alibabaConsoleCookie: "",
qwenCloudCookie: "",
qwenCloudSecToken: "",
alibabaConsoleSecToken: "",
volcConsoleCookie: "",
ccCompatibleContext1m: ccRequestDefaults.context1m,
ccCompatibleRedactThinking: ccRequestDefaults.redactThinking,
ccCompatibleSummarizeThinking: ccRequestDefaults.summarizeThinking,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
assignQuotaScrapingProviderData,
EMPTY_QUOTA_SCRAPING_FIELDS,
QWEN_TOKEN_PLAN_PROVIDERS,
VOLCENGINE_PLAN_PROVIDERS,
type QuotaScrapingFieldValues,
} from "./quotaScrapingFieldValues";

Expand Down Expand Up @@ -149,5 +150,33 @@ export default function QuotaScrapingFields({
);
}

if (VOLCENGINE_PLAN_PROVIDERS.has(provider ?? "")) {
return (
<div className="flex flex-col gap-3 rounded-lg border border-border/50 bg-surface/20 p-4">
<Input
label={providerText(t, "volcConsoleCookieLabel", "Volcano Engine console cookie")}
name="volcConsoleCookie"
type="password"
value={values.volcConsoleCookie}
onChange={(e) => onChange({ volcConsoleCookie: e.target.value })}
placeholder="session=...; AccountID=..."
hint={providerText(
t,
"volcConsoleCookieHint",
editMode
? "Leave blank to keep the stored cookie. To rotate, paste the updated cookie string from console.volcengine.com."
: "Required for Volcano Ark Plan quota -- the inference API key cannot read it. " +
"How to get it: log in to console.volcengine.com, open Developer Tools (F12), " +
"run document.cookie (or inspect Network headers), and paste the cookie string here. " +
"It expires with your browser session; re-paste when quota reports an expired session."
)}
autoComplete="off"
spellCheck={false}
autoCapitalize="off"
/>
</div>
);
}

return null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@ import { getProviderConnectionFamilyIds } from "@/shared/constants/providers";
/** Providers whose quota lives behind the Qwen/Model Studio console gateway (#9603). */
export const QWEN_TOKEN_PLAN_PROVIDERS = new Set(["qwen-cloud-token-plan", "bailian-coding-plan"]);

/** Providers whose quota lives behind the Volcano Engine console gateway. */
export const VOLCENGINE_PLAN_PROVIDERS = new Set([
"volcengine-coding-plan",
"volcengine-agent-plan",
]);

export type QuotaScrapingFieldValues = {
ollamaCloudUsageCookie: string;
alibabaConsoleCookie: string;
alibabaConsoleSecToken: string;
qwenCloudCookie: string;
qwenCloudSecToken: string;
volcConsoleCookie: string;
};

export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = {
Expand All @@ -26,6 +33,7 @@ export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = {
alibabaConsoleSecToken: "",
qwenCloudCookie: "",
qwenCloudSecToken: "",
volcConsoleCookie: "",
};

export function assignQuotaScrapingProviderData(
Expand All @@ -51,5 +59,7 @@ export function assignQuotaScrapingProviderData(
if (values.qwenCloudSecToken?.trim()) {
target.qwenCloudSecToken = values.qwenCloudSecToken.trim();
}
} else if (VOLCENGINE_PLAN_PROVIDERS.has(provider ?? "") && values.volcConsoleCookie?.trim()) {
target.volcConsoleCookie = values.volcConsoleCookie.trim();
}
}
1 change: 1 addition & 0 deletions src/shared/validation/providerSpecificData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ export function validateProviderSpecificData(
"alibabaConsoleSecToken",
"qwenCloudCookie",
"qwenCloudSecToken",
"volcConsoleCookie",
] as const) {
const value = data[key];
if (value !== undefined && value !== null && typeof value !== "string") {
Expand Down
100 changes: 100 additions & 0 deletions tests/unit/volcengine-plan-cookie-field.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* volcengine-plan-cookie-field.test.ts — Volcano Ark Coding/Agent Plan
* quota fetchers are cookie-authenticated (API keys cannot query the console
* quota API). Expose volcConsoleCookie in QuotaScrapingFields so users can
* paste their console cookie from the dashboard without needing local browser automation.
*/
import test from "node:test";
import assert from "node:assert/strict";

import {
EMPTY_QUOTA_SCRAPING_FIELDS,
assignQuotaScrapingProviderData,
} from "../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts";
import { extractErrorMessage } from "../../src/shared/utils/upstreamError.ts";
const { updateProviderConnectionSchema } = await import("../../src/shared/validation/schemas.ts");

test("volcengine-coding-plan and volcengine-agent-plan persist the console cookie", () => {
for (const provider of ["volcengine-coding-plan", "volcengine-agent-plan"]) {
const target: Record<string, unknown> = {};

assignQuotaScrapingProviderData(
provider,
{
...EMPTY_QUOTA_SCRAPING_FIELDS,
volcConsoleCookie: " session=volc-123; AccountID=acc-456 ",
},
target
);

assert.equal(
target.volcConsoleCookie,
"session=volc-123; AccountID=acc-456",
`cookie must be stored trimmed for ${provider}`
);
}
});

test("a blank volcConsoleCookie does not overwrite the stored one", () => {
for (const provider of ["volcengine-coding-plan", "volcengine-agent-plan"]) {
const target: Record<string, unknown> = {};

assignQuotaScrapingProviderData(
provider,
{ ...EMPTY_QUOTA_SCRAPING_FIELDS, volcConsoleCookie: " " },
target
);

assert.equal(
Object.hasOwn(target, "volcConsoleCookie"),
false,
"blank input must leave the stored cookie untouched"
);
}
});

test("a form object without volcConsoleCookie does not throw", () => {
const target: Record<string, unknown> = {};
const partial = { ...EMPTY_QUOTA_SCRAPING_FIELDS } as Record<string, string>;
delete partial.volcConsoleCookie;

for (const provider of ["volcengine-coding-plan", "volcengine-agent-plan"]) {
assert.doesNotThrow(() =>
assignQuotaScrapingProviderData(
provider,
partial as unknown as typeof EMPTY_QUOTA_SCRAPING_FIELDS,
target
)
);
}
assert.equal(Object.hasOwn(target, "volcConsoleCookie"), false);
});

test("providerSpecificData validation guards the volcConsoleCookie field", () => {
const ok = updateProviderConnectionSchema.safeParse({
providerSpecificData: { volcConsoleCookie: "session=volc-abc" },
});
assert.equal(ok.success, true, JSON.stringify(ok.error?.issues));

const wrongType = updateProviderConnectionSchema.safeParse({
providerSpecificData: { volcConsoleCookie: 42 },
});
assert.equal(wrongType.success, false, "non-string cookie must be rejected");

const tooLong = updateProviderConnectionSchema.safeParse({
providerSpecificData: { volcConsoleCookie: "x".repeat(10_001) },
});
assert.equal(tooLong.success, false, "oversized cookie must be rejected");
});

test("extractErrorMessage extracts message from structured error objects instead of [object Object]", () => {
const localOnlyError = {
code: "LOCAL_ONLY",
message: "This endpoint requires localhost access",
};
const extracted = extractErrorMessage(localOnlyError);
assert.equal(extracted, "This endpoint requires localhost access");

const stringError = "Failed to start Volcano login";
assert.equal(extractErrorMessage(stringError), null);
});
Loading