diff --git a/bin/cli/commands/provider-crud.mjs b/bin/cli/commands/provider-crud.mjs index f1de0b6c31a0..7d0adcede21b 100644 --- a/bin/cli/commands/provider-crud.mjs +++ b/bin/cli/commands/provider-crud.mjs @@ -102,6 +102,7 @@ export function buildProviderPayload(provider, opts = {}, credential) { }; if (!body.name) throw new Error("Provider name is required."); if (!isBlank(credential)) body.apiKey = String(credential); + if (opts.allowNoCredential === true) body.allowNoCredential = true; if (!isBlank(opts.defaultModel)) body.defaultModel = String(opts.defaultModel).trim(); if (!isBlank(opts.priority)) { const priority = Number(opts.priority); diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 06df5695445e..250116b6d8b3 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -153,9 +153,12 @@ export async function GET(request: Request) { ? { codexAccountPool: projectCodexAccountPoolWithRoutingQuota( { - id: c.id, + id: String(c.id), provider: c.provider, - providerSpecificData: c.providerSpecificData ?? {}, + providerSpecificData: + c.providerSpecificData && typeof c.providerSpecificData === "object" + ? (c.providerSpecificData as Readonly>) + : {}, }, Date.now(), quotaCache[String(c.id)] @@ -196,12 +199,19 @@ export async function POST(request: Request) { defaultModel, testStatus, providerSpecificData: incomingPsd, + allowNoCredential, } = validation.data; const provider = resolveProviderId(requestedProvider); const retirementResponse = rejectRetiredCommonChatGptWebProvider(requestedProvider) ?? rejectRetiredCommonChatGptWebProvider(provider); if (retirementResponse) return retirementResponse; + if (allowNoCredential === true && !providerAllowsOptionalApiKey(provider)) { + return NextResponse.json( + { error: "This provider does not allow a connection without a credential" }, + { status: 400 } + ); + } // Business validation const isValidProvider = diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index 12eee7eeb860..ee5bd9ce0bc4 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -94,6 +94,7 @@ export const createProviderSchema = z globalPriority: z.number().int().min(1).max(100).nullable().optional(), defaultModel: z.string().max(200).nullable().optional(), testStatus: z.string().max(50).optional(), + allowNoCredential: z.literal(true).optional(), providerSpecificData: z .record(z.string(), z.unknown()) .optional() @@ -103,8 +104,15 @@ export const createProviderSchema = z }) .superRefine((data, ctx) => { const apiKey = typeof data.apiKey === "string" ? data.apiKey.trim() : ""; - const apiKeyOptional = providerAllowsOptionalApiKey(data.provider); - if (!apiKeyOptional && apiKey.length === 0) { + const catalogAllowsOptionalKey = providerAllowsOptionalApiKey(data.provider); + if (data.allowNoCredential === true && !catalogAllowsOptionalKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "This provider does not allow a connection without a credential", + path: ["allowNoCredential"], + }); + } + if (!catalogAllowsOptionalKey && apiKey.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "API key is required", diff --git a/tests/unit/cli/provider-crud.test.ts b/tests/unit/cli/provider-crud.test.ts index c3b71e133e6a..f43413daac89 100644 --- a/tests/unit/cli/provider-crud.test.ts +++ b/tests/unit/cli/provider-crud.test.ts @@ -110,6 +110,27 @@ test("negated --no-credential is treated as a control flag, not the literal stri }); }); +test("allow-no-credential is sent in the add-provider body only when the flag is set", () => { + const allowed = buildProviderPayload( + "ollama-local", + { name: "local", allowNoCredential: true }, + undefined + ); + assert.equal(allowed.allowNoCredential, true); + assert.equal("apiKey" in allowed, false); + + const omitted = buildProviderPayload("ollama-local", { name: "local" }, undefined); + assert.equal("allowNoCredential" in omitted, false); + + const withKey = buildProviderPayload( + "glm", + { name: "work", allowNoCredential: true }, + "provider-secret" + ); + assert.equal(withKey.allowNoCredential, true); + assert.equal(withKey.apiKey, "provider-secret"); +}); + test("provider JSON output redacts raw credentials recursively", () => { const redacted = redactProviderResponse({ connection: {