Skip to content

Commit 688fd77

Browse files
committed
fix(providers): disclose the Volcengine Plan usage restriction
Volcengine documents Coding Plan and Agent Plan quota as valid only inside supported AI coding tools, and warns that using a plan key for general API calls may suspend the subscription or ban the account. Routing Codex or Claude Code through opencodex is the vendor's documented use, so the presets stay canonical — but the user was never told about the boundary they must not cross. `tencent-coding-plan` already ships this disclosure; these two entries did not. - carry the restriction in both Plan `note` strings, worded per product so Agent Plan does not quote Coding-Plan-specific policy - resolve the DTO note by DESTINATION rather than by provider name. The dashboard lets a preset be saved under any name, and a renamed row kept the credential destination while silently losing the warning about it - declare the text-only Plan models so the vision sidecar cannot advertise image input for models that cannot accept it, matching the Tencent precedent - document the restriction in the provider guide The custom-name regression fails on the previous name-only lookup, verified by reverting the resolver and re-running the test.
1 parent 3a37dda commit 688fd77

4 files changed

Lines changed: 129 additions & 8 deletions

File tree

docs-site/src/content/docs/guides/providers.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,12 @@ Volcengine Agent Plan uses its native Responses endpoint through `openai-respons
241241
> models. Coding Plan defaults to `ark-code-latest`, while Agent Plan defaults to
242242
> `deepseek-v4-pro`.
243243
244+
> **Volcengine Plan usage restriction:** Volcengine documents Coding Plan and Agent Plan quota as
245+
> valid only inside supported AI coding tools, and warns that using a plan key for general API
246+
> calls may suspend the subscription or ban the account. Routing Codex or Claude Code through
247+
> opencodex is the documented use; pointing other automation at a plan key is not. The
248+
> pay-as-you-go `volcengine` route carries no such restriction.
249+
244250
**DeepInfra discovery.** The key-based `deepinfra` OpenAI Chat Completions provider uses the
245251
`openai-chat` adapter with a Bearer API key. Its registry-owned model-list URL keeps only rows tagged
246252
`chat`, preserves slash-containing native model ids, and caps live discovery at 512 KiB and 512 raw

src/providers/registry.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,6 @@ export interface ProviderRegistryEntry {
104104
adapter: string;
105105
baseUrl: string;
106106
apiKeyTransport?: OcxProviderConfig["apiKeyTransport"];
107-
/** Relative Responses resource for key-auth openai-responses gateways with versioned bases. */
108-
responsesPath?: string;
109107
authKind: ProviderAuthKind;
110108
codexAccountMode?: CodexAccountMode;
111109
/** OAuth preset may explicitly honor a persisted API-key billing mode. */
@@ -405,6 +403,17 @@ const VOLCENGINE_PLAN_INPUT_MODALITIES: Record<string, string[]> = {
405403
"kimi-k2.6": ["text", "image"],
406404
"minimax-m3": ["text", "image"],
407405
};
406+
// Every other Plan model is text-only. Declaring this explicitly keeps the vision
407+
// sidecar from advertising image input for models that cannot accept it — the same
408+
// treatment tencent-coding-plan gives its (entirely text-only) plan catalog.
409+
const VOLCENGINE_PLAN_TEXT_ONLY_MODELS = [
410+
"ark-code-latest",
411+
"doubao-seed-2.0-code",
412+
"deepseek-v4-pro",
413+
"deepseek-v4-flash",
414+
"glm-5.2",
415+
"doubao-seed-2.0-pro",
416+
];
408417
const ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES: Record<string, string[]> = {
409418
"qwen3.8-max-preview": ["text", "image"],
410419
"qwen3.7-max": ["text", "image"],
@@ -1146,14 +1155,15 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
11461155
models: VOLCENGINE_CODING_PLAN_MODELS,
11471156
liveModels: false,
11481157
modelInputModalities: VOLCENGINE_PLAN_INPUT_MODALITIES,
1158+
noVisionModels: VOLCENGINE_PLAN_TEXT_ONLY_MODELS,
11491159
modelReasoningEfforts: Object.fromEntries(
11501160
DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_EFFORTS]),
11511161
),
11521162
modelReasoningEffortMap: Object.fromEntries(
11531163
DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_REASONING_MAP]),
11541164
),
11551165
preserveReasoningContentModels: DEEPSEEK_THINKING_MODELS,
1156-
note: "Coding Plan subscription endpoint with plan-scoped model aliases. Use the plan key issued by the Ark console.",
1166+
note: "Coding tools only. Volcengine restricts Coding Plan quota to supported AI coding tools and warns that using this key for general API calls may suspend the subscription or ban the account. Use the plan key issued by the Ark console.",
11571167
},
11581168
{
11591169
id: "volcengine-agent-plan",
@@ -1168,7 +1178,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
11681178
models: VOLCENGINE_AGENT_PLAN_MODELS,
11691179
liveModels: false,
11701180
modelInputModalities: VOLCENGINE_PLAN_INPUT_MODALITIES,
1171-
note: "Agent Plan subscription endpoint over the native Responses API with a static fallback catalog.",
1181+
noVisionModels: VOLCENGINE_PLAN_TEXT_ONLY_MODELS,
1182+
note: "Coding tools only. Agent Plan is a subscription endpoint over the native Responses API with a static fallback catalog; Ark plan quota is intended for supported AI coding and agent tools, so avoid using this key as a general-purpose API key.",
11721183
},
11731184
// 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md.
11741185
{ id: "qianfan", label: "Qianfan (Baidu)", baseUrl: "https://qianfan.baidubce.com/v2", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.bce.baidu.com/iam/#/iam/apikey/list" },
@@ -1439,6 +1450,32 @@ export function providerMatchesRegistryTransport(
14391450
return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl);
14401451
}
14411452

1453+
/**
1454+
* Resolve the registry entry a configured provider actually points at, by TRANSPORT
1455+
* rather than by name.
1456+
*
1457+
* `providerMatchesRegistryTransport` answers "does the row named X still point at X's
1458+
* documented destination", which is the right question for routing but the wrong one
1459+
* for user-facing metadata: the GUI lets a preset be saved under any name, and a
1460+
* renamed row would silently lose a usage restriction it still needs to display.
1461+
*
1462+
* Only fixed key destinations are matched. Entries with an overridable or templated
1463+
* base URL are skipped, because their configured URL cannot identify one vendor route.
1464+
*/
1465+
export function registryEntryForProviderDestination(
1466+
provider: Pick<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode">>,
1467+
): ProviderRegistryEntry | undefined {
1468+
if (typeof provider.baseUrl !== "string" || !provider.baseUrl) return undefined;
1469+
if (provider.authMode !== undefined && provider.authMode !== "key") return undefined;
1470+
const endpoint = normalizedProviderEndpoint(provider.baseUrl);
1471+
return PROVIDER_REGISTRY.find(entry =>
1472+
entry.authKind === "key"
1473+
&& !entry.allowBaseUrlOverride
1474+
&& !/\{[^}]*\}/.test(entry.baseUrl)
1475+
&& entry.adapter === provider.adapter
1476+
&& normalizedProviderEndpoint(entry.baseUrl) === endpoint);
1477+
}
1478+
14421479
/**
14431480
* Resolve a registry-only default for a mixed-wire provider. Defaults only move a provider
14441481
* between the two OpenAI-shaped adapters and never override a provider configured on another

src/server/auth-cors.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
reasoningSummaryDeliveryRecordConfigError,
1313
} from "../config";
1414
import { providerDestinationConfigError } from "../lib/destination-policy";
15-
import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport } from "../providers/registry";
15+
import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
1616
import { providerConfigSeed } from "../providers/derive";
1717
import type { OcxConfig, OcxProviderConfig } from "../types";
1818
import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
@@ -504,9 +504,13 @@ export function safeConfigDTO(config: OcxConfig): unknown {
504504
] as const) {
505505
copyIfDefined(dto, provider, key);
506506
}
507-
const registryNote = providerMatchesRegistryTransport(name, provider)
508-
? getProviderRegistryEntry(name)?.note
509-
: undefined;
507+
// Resolve the note by DESTINATION, not by name. A preset saved under a custom name is
508+
// still pointed at the same vendor route, and a usage restriction the user needs to see
509+
// must not disappear because the row was renamed. Prefer the same-name entry so an
510+
// unrenamed provider keeps its exact registry note.
511+
const registryNote = (providerMatchesRegistryTransport(name, provider)
512+
? getProviderRegistryEntry(name)
513+
: registryEntryForProviderDestination(provider))?.note;
510514
if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote;
511515
const codexAccountMode = providerCodexAccountMode(name, provider);
512516
if (codexAccountMode) dto.codexAccountMode = codexAccountMode;

tests/volcengine-providers.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { applyProviderConfigHints, buildCatalogEntries } from "../src/codex/cata
55
import { KEY_LOGIN_PROVIDERS } from "../src/oauth/key-providers";
66
import { deriveProviderPresets, providerConfigSeed } from "../src/providers/derive";
77
import { PROVIDER_REGISTRY } from "../src/providers/registry";
8+
import { safeConfigDTO } from "../src/server/auth-cors";
89
import { routeModel } from "../src/router";
910
import type { OcxConfig } from "../src/types";
1011
import { buildProviderPostBody } from "../gui/src/provider-payload";
@@ -317,4 +318,77 @@ describe("Volcengine Ark providers", () => {
317318
expect(isCatalogProviderId("volcengine-coding-plan")).toBe(true);
318319
expect(isCatalogProviderId("volcengine-agent-plan")).toBe(true);
319320
});
321+
322+
// Volcengine documents Plan quota as valid only inside supported AI coding tools and warns
323+
// that other API use of the key may suspend the subscription or ban the account. The note is
324+
// the only surface that tells a user this, so both Plan presets must carry it and the
325+
// pay-as-you-go route — which has no such restriction — must not.
326+
test("both Plan presets disclose the coding-tools-only restriction", () => {
327+
const codingPlan = PROVIDER_REGISTRY.find(provider => provider.id === "volcengine-coding-plan")!;
328+
const agentPlan = PROVIDER_REGISTRY.find(provider => provider.id === "volcengine-agent-plan")!;
329+
const payg = PROVIDER_REGISTRY.find(provider => provider.id === "volcengine")!;
330+
331+
expect(codingPlan.note).toContain("Coding tools only");
332+
expect(codingPlan.note).toMatch(/suspend the subscription or ban the account/);
333+
expect(agentPlan.note).toContain("Coding tools only");
334+
expect(payg.note).not.toContain("Coding tools only");
335+
});
336+
337+
test("Plan presets declare their text-only models so the vision sidecar cannot over-advertise", () => {
338+
for (const id of ["volcengine-coding-plan", "volcengine-agent-plan"]) {
339+
const entry = PROVIDER_REGISTRY.find(provider => provider.id === id)!;
340+
const multimodal = Object.entries(entry.modelInputModalities ?? {})
341+
.filter(([, modalities]) => modalities.includes("image"))
342+
.map(([model]) => model);
343+
344+
// Every advertised model is either declared multimodal or declared text-only —
345+
// nothing is left to the default.
346+
for (const model of entry.models ?? []) {
347+
const declared = multimodal.includes(model) || (entry.noVisionModels ?? []).includes(model);
348+
expect(declared).toBe(true);
349+
}
350+
// A text-only model is never also advertised as accepting images.
351+
for (const model of entry.noVisionModels ?? []) {
352+
expect(multimodal).not.toContain(model);
353+
}
354+
}
355+
});
356+
357+
// The dashboard lets a preset be saved under any name. Resolving the note by name alone
358+
// silently dropped the usage restriction for a renamed row — the user kept the same
359+
// credential destination but lost the warning about it.
360+
test("the Plan restriction survives saving the preset under a custom name", () => {
361+
const codingPlan = PROVIDER_REGISTRY.find(provider => provider.id === "volcengine-coding-plan")!;
362+
const dto = safeConfigDTO({
363+
port: 10100,
364+
defaultProvider: "my-volc",
365+
providers: {
366+
"my-volc": {
367+
adapter: codingPlan.adapter,
368+
baseUrl: codingPlan.baseUrl,
369+
authMode: "key",
370+
apiKey: "sk-test-not-a-real-key",
371+
},
372+
},
373+
} as unknown as OcxConfig) as { providers: Record<string, { note?: string }> };
374+
375+
expect(dto.providers["my-volc"].note).toContain("Coding tools only");
376+
});
377+
378+
test("an unrelated destination does not inherit a Volcengine restriction", () => {
379+
const dto = safeConfigDTO({
380+
port: 10100,
381+
defaultProvider: "somewhere-else",
382+
providers: {
383+
"somewhere-else": {
384+
adapter: "openai-chat",
385+
baseUrl: "https://api.example.test/v1",
386+
authMode: "key",
387+
apiKey: "sk-test-not-a-real-key",
388+
},
389+
},
390+
} as unknown as OcxConfig) as { providers: Record<string, { note?: string }> };
391+
392+
expect(dto.providers["somewhere-else"].note).toBeUndefined();
393+
});
320394
});

0 commit comments

Comments
 (0)