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
19 changes: 10 additions & 9 deletions open-sse/config/providers/registry/qwen/web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@ export const qwen_webProvider: RegistryEntry = {
alias: "qwen-web",
format: "openai",
executor: "qwen-web",
baseUrl: "https://chat.qwen.ai/api/chat/completions",
// v2 API (the legacy /api/chat/completions endpoint was retired upstream).
// Restored after the registry modularization (#3993) regressed this to v1 with
// a retired catalog. Source of truth: pre-#3993 providerRegistry.ts (commit 1ed01dd90^).
baseUrl: "https://chat.qwen.ai/api/v2/chat/completions",
authType: "apikey",
authHeader: "bearer",
// Current upstream catalog (GET https://chat.qwen.ai/api/models). Legacy
// ids (qwen-plus, qwen3-max, ...) still resolve via the executor's
// MODEL_ALIASES map for backward compatibility.
models: [
{ id: "qwen-plus", name: "Qwen Plus" },
{ id: "qwen-max", name: "Qwen Max" },
{ id: "qwen-turbo", name: "Qwen Turbo" },
{ id: "qwen3-plus", name: "Qwen3 Plus" },
{ id: "qwen3-max", name: "Qwen3 Max" },
{ id: "qwen3-flash", name: "Qwen3 Flash" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus" },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus" },
],
};
6 changes: 6 additions & 0 deletions open-sse/config/providers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,12 @@ export const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
"mistralai/Mistral-7B-Instruct-v0.3",
"Qwen/Qwen2.5-72B-Instruct",
]),
// Restored after the registry modularization (#3993) dropped the mimocode key
// referenced by the mimocode provider plugin. Source of truth: pre-#3993
// providerRegistry.ts (commit 1ed01dd90^).
mimocode: [
{ id: "mimo-auto", name: "MiMo Auto", contextLength: 1000000, maxOutputTokens: 128000 },
],
};

export function mapStainlessOs() {
Expand Down
1 change: 1 addition & 0 deletions src/shared/validation/schemas/combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const comboRuntimeConfigSchema = z
maxMessagesForSummary: z.coerce.number().int().min(5).max(100).optional(),
maxComboDepth: z.coerce.number().int().min(1).max(10).optional(),
trackMetrics: z.boolean().optional(),
reasoningTokenBufferEnabled: z.boolean().optional(),
compressionMode: compressionModeSchema.optional(),
failoverBeforeRetry: z.boolean().optional(),
maxSetRetries: z.coerce.number().int().min(0).max(10).optional(),
Expand Down
49 changes: 17 additions & 32 deletions src/shared/validation/schemas/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,38 +28,23 @@ export const createSyncTokenSchema = z.object({
name: z.string().trim().min(1, "Name is required").max(200),
});

export const setBudgetSchema = z
.object({
apiKeyId: z.string().trim().min(1, "apiKeyId is required"),
dailyLimitUsd: z.coerce.number().positive("dailyLimitUsd must be greater than zero").optional(),
weeklyLimitUsd: z.coerce
.number()
.positive("weeklyLimitUsd must be greater than zero")
.optional(),
monthlyLimitUsd: z.coerce
.number()
.positive("monthlyLimitUsd must be greater than zero")
.optional(),
warningThreshold: z.coerce.number().min(0).max(1).optional(),
resetInterval: z.enum(["daily", "weekly", "monthly"]).optional(),
resetTime: z
.string()
.trim()
.regex(/^\d{2}:\d{2}$/, "resetTime must be in HH:MM format")
.optional(),
})
.superRefine((value, ctx) => {
const hasAnyLimit = [value.dailyLimitUsd, value.weeklyLimitUsd, value.monthlyLimitUsd].some(
(entry) => typeof entry === "number" && Number.isFinite(entry) && entry > 0
);
if (!hasAnyLimit) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one budget limit must be provided",
path: ["dailyLimitUsd"],
});
}
});
export const setBudgetSchema = z.object({
apiKeyId: z.string().trim().min(1, "apiKeyId is required"),
// #3537: a limit of 0 means "no limit for this period" (checkBudget only enforces when
// activeLimitUsd > 0). The dashboard sends 0 for unfilled fields, so 0 must be accepted —
// `.positive()` (rejects 0) used to 400 any save that left a field blank. Negatives are
// still rejected by `.min(0)`.
dailyLimitUsd: z.coerce.number().min(0, "dailyLimitUsd must be zero or greater").optional(),
weeklyLimitUsd: z.coerce.number().min(0, "weeklyLimitUsd must be zero or greater").optional(),
monthlyLimitUsd: z.coerce.number().min(0, "monthlyLimitUsd must be zero or greater").optional(),
warningThreshold: z.coerce.number().min(0).max(1).optional(),
resetInterval: z.enum(["daily", "weekly", "monthly"]).optional(),
resetTime: z
.string()
.trim()
.regex(/^\d{2}:\d{2}$/, "resetTime must be in HH:MM format")
.optional(),
});

export const setTokenLimitSchema = z
.object({
Expand Down
13 changes: 13 additions & 0 deletions src/shared/validation/schemas/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints";
import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize";
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
import {
OPENROUTER_PRESET_MAX_LENGTH,
isOpenRouterPresetValue,
} from "@/shared/constants/openRouterPreset";
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
import {
isForbiddenUpstreamHeaderName,
Expand Down Expand Up @@ -102,6 +106,15 @@ export function validateProviderSpecificData(
});
}

const preset = data.preset;
if (preset !== undefined && preset !== null && !isOpenRouterPresetValue(preset)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `providerSpecificData.preset must be a string up to ${OPENROUTER_PRESET_MAX_LENGTH} chars`,
path: ["preset"],
});
}

const requestDefaults = data.requestDefaults;
if (requestDefaults !== undefined) {
if (!requestDefaults || typeof requestDefaults !== "object" || Array.isArray(requestDefaults)) {
Expand Down
3 changes: 3 additions & 0 deletions src/shared/validation/schemas/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ export const proxyRegistryFieldsSchema = z
notes: z.string().trim().max(1000).nullable().optional(),
status: z.enum(["active", "inactive"]).optional().default("active"),
source: z.enum(["manual", "oneproxy", "dashboard-custom", "vercel-relay"]).optional(),
// Address-family egress policy (#3777): "auto" keeps the prior dual-stack behavior;
// "ipv4"/"ipv6" pin the connection to that family (no v4 leak under an IPv6-only proxy).
family: z.enum(["auto", "ipv4", "ipv6"]).optional().default("auto"),
})
.strict();

Expand Down
34 changes: 31 additions & 3 deletions src/shared/validation/schemas/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,25 @@ export const connectionCooldownProfileSchema = z

export const providerBreakerProfileSchema = z
.object({
failureThreshold: z.number().int().min(1).optional(),
failureThreshold: z.number().int().min(1).max(1000).optional(),
degradationThreshold: z.number().int().min(1).max(1000).optional(),
resetTimeoutMs: z.number().int().min(1000).optional(),
})
.strict();
.strict()
.superRefine((value, ctx) => {
if (
typeof value.failureThreshold === "number" &&
value.failureThreshold > 1 &&
typeof value.degradationThreshold === "number" &&
value.degradationThreshold >= value.failureThreshold
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "degradationThreshold must be lower than failureThreshold",
path: ["degradationThreshold"],
});
}
});

export const waitForCooldownSettingsSchema = z
.object({
Expand All @@ -140,6 +155,14 @@ export const waitForCooldownSettingsSchema = z
})
.strict();

export const providerCooldownSettingsSchema = z
.object({
enabled: z.boolean().optional(),
minRetryCooldownMs: z.number().int().min(0).max(300000).optional(),
maxRetryCooldownMs: z.number().int().min(0).max(3600000).optional(),
})
.strict();

export const updateResilienceSchema = z
.object({
requestQueue: requestQueueSettingsSchema.optional(),
Expand All @@ -158,6 +181,7 @@ export const updateResilienceSchema = z
.strict()
.optional(),
waitForCooldown: waitForCooldownSettingsSchema.optional(),
providerCooldown: providerCooldownSettingsSchema.optional(),
profiles: z
.object({
oauth: legacyResilienceProfileSchema.optional(),
Expand All @@ -174,6 +198,7 @@ export const updateResilienceSchema = z
!value.connectionCooldown &&
!value.providerBreaker &&
!value.waitForCooldown &&
!value.providerCooldown &&
!value.profiles &&
!value.defaults
) {
Expand Down Expand Up @@ -251,7 +276,10 @@ export const updateThinkingBudgetSchema = z
export const guideSettingsSaveSchema = z
.object({
baseUrl: z.string().trim().min(1).optional(),
apiKey: z.string().optional(),
// #3552: the CLI tool cards post `apiKey: null` in cloud mode (the real key is resolved
// server-side from keyId), and `z.string().optional()` rejected null → 400. Normalize
// null → undefined so validation passes and the keyId/default path is used.
apiKey: z.preprocess((v) => (v === null ? undefined : v), z.string().optional()),
model: z.string().trim().min(1, "Model is required").optional(),
models: z.array(z.string().trim().min(1, "Models must be non-empty")).min(1).optional(),
modelLabels: z.record(z.string(), z.string().trim().min(1)).optional(),
Expand Down
25 changes: 18 additions & 7 deletions tests/unit/check-public-creds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,30 @@ test("real scanned files produce ZERO violations with the frozen allowlist (gate
});

test("every frozen literal is actually present in a scanned file (no dead allowlist entries)", () => {
const scanned = [
// Anchor files for bare-value frozen entries. The registry was modularized into
// per-provider plugins (#3993), so providerRegistry.ts is now a re-export barrel;
// entries keyed by an explicit `file:line:value` are checked against the file named
// in the key (which is where the literal actually lives), not this anchor blob.
const anchorFiles = [
"open-sse/config/providerRegistry.ts",
"src/lib/oauth/constants/oauth.ts",
];
const blob = scanned
const anchorBlob = anchorFiles
.map((rel) => fs.readFileSync(path.join(repoRoot, rel), "utf8") as string)
.join("\n");
for (const entry of KNOWN_LITERAL_CREDS) {
// Plain value entries (no file:line: prefix) must appear verbatim in the source.
const value = entry.includes(":") && /:\d+:/.test(entry)
? entry.replace(/^.*?:\d+:/, "")
: entry;
assert.ok(blob.includes(value), `frozen literal not found in any scanned file: ${value}`);
const keyed = entry.includes(":") && /:\d+:/.test(entry);
const value = keyed ? entry.replace(/^.*?:\d+:/, "") : entry;
if (keyed) {
// `file:line:value` entry — the literal must still be present in its own source
// file (this is what makes the entry "not dead"). The line may have drifted, so
// match on file content rather than the exact line number.
const file = entry.replace(/:\d+:.*$/, "");
const src = fs.readFileSync(path.join(repoRoot, file), "utf8") as string;
assert.ok(src.includes(value), `frozen literal not found in its source file ${file}: ${value}`);
} else {
assert.ok(anchorBlob.includes(value), `frozen literal not found in any anchor file: ${value}`);
}
}
});

Expand Down
7 changes: 6 additions & 1 deletion tests/unit/executor-puter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ test("PuterExecutor.execute uses inherited BaseExecutor flow", async () => {
});

assert.equal(result.response.status, 200);
assert.equal(result.transformedBody, body);
// #3941 ("Capture actual upstream provider requests") makes BaseExecutor return
// transformedBody as the JSON-round-tripped serialized body (the real on-the-wire
// payload, post fingerprint/sign), so it is a structurally-equal new object rather
// than the same reference. Puter's transformRequest is still a passthrough, so the
// body must match structurally.
assert.deepEqual(result.transformedBody, body);
assert.equal(result.url, "https://api.puter.com/puterai/openai/v1/chat/completions");
assert.equal(captured.options.headers.Authorization, "Bearer puter-key");
assert.equal(captured.options.body, JSON.stringify(body));
Expand Down
9 changes: 7 additions & 2 deletions tests/unit/oauth-providers-error-handling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ const read = (rel: string) => readFile(path.join(root, rel), "utf8");
// ─── P0: GitLab Duo ───────────────────────────────────────────────────────────

test("P0: gitlab-duo is registered in providerRegistry", async () => {
const src = await read("open-sse/config/providerRegistry.ts");
assert.match(src, /["']gitlab-duo["']\s*:/, "gitlab-duo must be a key in REGISTRY");
// The provider registry was modularized into per-provider plugin files (#3993),
// so a text grep of providerRegistry.ts (now a thin re-export barrel) no longer
// finds the literal key. Assert registration at runtime instead, preserving the
// test's intent ("gitlab-duo is registered").
const { REGISTRY, getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts");
assert.ok("gitlab-duo" in REGISTRY, "gitlab-duo must be a key in REGISTRY");
assert.ok(getRegistryEntry("gitlab-duo"), "getRegistryEntry('gitlab-duo') must be non-null");
});

test("P0: refreshGitLabDuoToken exists and handles invalid_grant as unrecoverable", async () => {
Expand Down
Loading