Skip to content
Open
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
10 changes: 5 additions & 5 deletions kits/firestore-genai-chatbot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,17 @@ the CLI connects them to the function at deploy time.
|---|---|---|---|---|
| `provider` | `GENERATIVE_AI_PROVIDER` | no | `google-ai` | `google-ai` or `vertex-ai` |
| `apiKey` | `API_KEY` | secret | — | Google AI API key |
| `model` | `MODEL` | no | `gemini-2.5-flash` | Model id |
| `vertexModelLocation` | `VERTEX_AI_MODEL_LOCATION` | no | `null` | Vertex model region |
| `model` | `MODEL` | no | `gemini-3.6-flash` | Model id. Shape-checked at deploy time; not verified against the provider |
| `vertexModelLocation` | `VERTEX_AI_MODEL_LOCATION` | no | `global` | Vertex model region. Gemini 3.x is only served on `global`, `us` and `eu` |
| `collectionName` | `COLLECTION_NAME` | no | `generate` | Discussion collection |
| `promptField` | `PROMPT_FIELD` | no | `prompt` | Prompt field name |
| `responseField` | `RESPONSE_FIELD` | no | `response` | Response field name |
| `orderField` | `ORDER_FIELD` | no | `createTime` | Ordering field |
| `candidatesField` | `CANDIDATES_FIELD` | no | `candidates` | Candidates field name |
| `context` | `CONTEXT` | no | (empty) | System context |
| `temperature` | `TEMPERATURE` | no | (empty) | Sampling temperature |
| `topP` | `TOP_P` | no | (empty) | Top-p |
| `topK` | `TOP_K` | no | (empty) | Top-k |
| `temperature` | `TEMPERATURE` | no | (empty) | Sampling temperature. Ignored by Gemini 3.x |
| `topP` | `TOP_P` | no | (empty) | Top-p. Ignored by Gemini 3.x |
| `topK` | `TOP_K` | no | (empty) | Top-k. Ignored by Gemini 3.x |
| `candidateCount` | `CANDIDATE_COUNT` | no | `1` | Candidate count |
| `maxOutputTokens` | `MAX_OUTPUT_TOKENS` | no | (empty) | Max output tokens |
| `enableOverrides` | `ENABLE_DISCUSSION_OPTION_OVERRIDES` | no | `false` | Per-discussion option overrides |
Expand Down
35 changes: 35 additions & 0 deletions kits/firestore-genai-chatbot/src/candidates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Whether this configuration asks for multiple candidate responses.
*
* The client-selection gate and the Firestore write path must agree: the Genkit
* client only serves single-candidate configs, and writing the `candidates`
* field requires a field name to write it to. Keeping one predicate stops the
* two from drifting, which would either waste a multi-candidate request or send
* a single-candidate config down the legacy clients.
*/
export function wantsMultipleCandidates(config: {
candidateCount?: number;
candidatesField?: string;
}): boolean {
return (
!!config.candidatesField &&
!!config.candidateCount &&
config.candidateCount > 1
);
}
48 changes: 45 additions & 3 deletions kits/firestore-genai-chatbot/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ import {
type SafetySetting,
} from "./export-config";

/**
* Shape check only — model ids are not validated against the provider, so an id
* that exists but is not served fails at request time. This catches typos like
* `gemini 3.6-flash` at deploy time instead of on every write.
*/
const MODEL_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9.\-_/]*$/;

const GENERATIVE_AI_PROVIDER_OPTIONS = ["google-ai", "vertex-ai"] as const;
const VERTEX_MODEL_LOCATION_OPTIONS = [
"null",
Expand Down Expand Up @@ -84,9 +91,28 @@ const params = {
input: select([...GENERATIVE_AI_PROVIDER_OPTIONS]),
}),
apiKey: defineSecret("API_KEY"),
model: defineString("MODEL", { default: "gemini-2.5-flash" }),
model: defineString("MODEL", {
default: "gemini-3.6-flash",
input: {
text: {
example: "gemini-3.6-flash",
validationRegex: MODEL_ID_PATTERN.source,
validationErrorMessage:
"Model ids have no spaces, for example 'gemini-3.6-flash'.",
},
},
}),
/**
* Vertex AI location for the model. Defaults to `global` rather than the
* function region: Gemini 3.x is served on the `global`, `us` and `eu`
* endpoints only, so a single region such as `us-central1` returns 404 for the
* default `gemini-3.6-flash`. Set a specific region only with a model that is
* served there (for example a Gemini 2.5 model).
*
* @see https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations
*/
vertexModelLocation: defineString("VERTEX_AI_MODEL_LOCATION", {
default: "null",
default: "global",
input: select([...VERTEX_MODEL_LOCATION_OPTIONS]),
}),
collectionName: defineString("COLLECTION_NAME", { default: "generate" }),
Expand All @@ -97,6 +123,12 @@ const params = {
default: "candidates",
}),
context: defineString("CONTEXT", { default: "" }),
/**
* Sampling controls. Gemini 3.x deprecates `temperature`, `topP` and `topK`;
* the Vertex AI model card for `gemini-3.6-flash` states custom values are
* ignored. They still apply to Gemini 2.5 models, which retire in October
* 2026, so the params are kept for existing configurations.
*/
temperature: defineString("TEMPERATURE", { default: "" }),
topP: defineString("TOP_P", { default: "" }),
topK: defineString("TOP_K", { default: "" }),
Expand Down Expand Up @@ -129,6 +161,16 @@ const params = {
/** The secret bound on the function so its value is available at runtime. */
export const apiKeySecret = params.apiKey;

/** Rejects a model id that cannot be a model id at all. */
function requireModelId(model: string): string {
if (!MODEL_ID_PATTERN.test(model)) {
throw new Error(
`MODEL must be a model id with no spaces, for example 'gemini-3.6-flash'. Received: '${model}'`
);
}
return model;
}

/** Coerce an empty-string param value to `undefined`. */
function optional(value: string): string | undefined {
return value.length > 0 ? value : undefined;
Expand Down Expand Up @@ -164,7 +206,7 @@ export function configFromEnv(): GenaiChatbotConfig {
(optional(params.provider.value()) as GenerativeAIProvider) ??
GenerativeAIProvider.GOOGLE_AI,
apiKey: params.apiKey.value(),
model: params.model.value(),
model: requireModelId(params.model.value()),
vertexModelLocation:
vertexModelLocation === "null" ? undefined : vertexModelLocation,
projectId: getProjectId(),
Expand Down
2 changes: 1 addition & 1 deletion kits/firestore-genai-chatbot/src/export-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface GenaiChatbotConfig {
provider?: GenerativeAIProvider | "google-ai" | "vertex-ai";
/** API key for the `google-ai` provider. */
apiKey?: string;
/** Model id, e.g. `gemini-2.5-flash`. */
/** Model id, e.g. `gemini-3.6-flash`. */
model: string;
/** Vertex AI model location. */
vertexModelLocation?: string;
Expand Down
15 changes: 10 additions & 5 deletions kits/firestore-genai-chatbot/src/generate-chat-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import type { DocumentSnapshot } from "firebase-admin/firestore";
import { wantsMultipleCandidates } from "./candidates";
import type { ResolvedGenaiChatbotConfig } from "./export-config";
import { fetchDiscussionOptions, fetchHistory } from "./firestore";
import { getGenerativeClient } from "./generative-client";
Expand Down Expand Up @@ -53,12 +54,16 @@ export function createGenerateChatResponse(config: ResolvedGenaiChatbotConfig) {
requestOptions = { ...requestOptions, ...discussionOptions };
}

const shouldAddCandidatesField =
config.candidatesField &&
requestOptions.candidateCount &&
requestOptions.candidateCount > 1;
// Per-discussion overrides can raise the candidate count, so the client
// gate and this write decision must both use the effective value.
const candidateCount =
requestOptions.candidateCount ?? config.candidateCount;
const shouldAddCandidatesField = wantsMultipleCandidates({
candidateCount,
candidatesField: config.candidatesField,
});

const discussionClient = getGenerativeClient(config);
const discussionClient = getGenerativeClient(config, candidateCount);
const result = await discussionClient.send(prompt, requestOptions);
const response = result.response;

Expand Down
76 changes: 23 additions & 53 deletions kits/firestore-genai-chatbot/src/generative-client/genkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from "genkit";
import { logger as genkitLogger } from "genkit/logging";
import type { GenkitPluginV2 } from "genkit/plugin";
import { wantsMultipleCandidates } from "../candidates";
import type { ResolvedGenaiChatbotConfig } from "../export-config";
import { logger } from "../logger";
import {
Expand Down Expand Up @@ -103,58 +104,24 @@ export class GenkitDiscussionClient extends DiscussionClient<
return genkit(genkitConfig);
}

// TODO(migration): inherited verbatim from the legacy extension — this
// hardcoded model allowlist means new/custom/fine-tuned models need a package
// update. `googleAI.model()` / `vertexAI.model()` resolve any id dynamically;
// consider simplifying to that. Improvement, not a bug. Deferred from PR #431 review.
/**
* Resolves a Genkit model reference via `googleAI.model()` / `vertexAI.model()`.
* Any id is passed through so current Gemini releases work without a package update.
*/
static createModelReference(
model: string,
provider: string
): ModelReference<any> {
const modelReferences =
provider === "google-ai"
? [
googleAI.model("gemini-1.5-flash"),
googleAI.model("gemini-1.5-pro"),
googleAI.model("gemini-2.0-flash"),
googleAI.model("gemini-2.0-flash-lite"),
googleAI.model("gemini-2.5-flash-lite"),
googleAI.model("gemini-2.5-flash"),
googleAI.model("gemini-2.5-pro"),
googleAI.model("gemini-3-pro-preview"),
googleAI.model("gemini-3-pro-image-preview"),
]
: [
vertexAI.model("gemini-1.5-flash"),
vertexAI.model("gemini-1.5-pro"),
vertexAI.model("gemini-2.0-flash"),
vertexAI.model("gemini-2.0-flash-lite"),
vertexAI.model("gemini-2.0-flash-001"),
vertexAI.model("gemini-2.5-flash-lite"),
vertexAI.model("gemini-2.5-flash"),
vertexAI.model("gemini-2.5-pro"),
vertexAI.model("gemini-3-pro-preview"),
vertexAI.model("gemini-3-pro-image-preview"),
];

const pluginName = provider === "google-ai" ? "googleai" : "vertexai";

for (const modelReference of modelReferences) {
if (modelReference.name === `${pluginName}/${model}`) {
return modelReference;
}
if (modelReference.info?.versions?.includes(model)) {
return modelReference.withVersion(model);
}
}
throw new Error("Model not found.");
return provider === "google-ai"
? googleAI.model(model)
: vertexAI.model(model);
}

private createGenerateOptions(
config: ResolvedGenaiChatbotConfig
): GenerateOptions {
if (!config.model) {
throw new Error("Model not found.");
throw new Error("Model must be specified in the configuration.");
}

return {
Expand All @@ -172,17 +139,20 @@ export class GenkitDiscussionClient extends DiscussionClient<
};
}

/** Whether the Genkit client can serve this config (single candidate + known model). */
static shouldUseGenkitClient(config: ResolvedGenaiChatbotConfig): boolean {
const shouldReturnMultipleCandidates =
config.candidateCount && config.candidateCount > 1;
return (
!shouldReturnMultipleCandidates &&
!!GenkitDiscussionClient.createModelReference(
config.model,
config.provider
)
);
/**
* Whether the Genkit client can serve this request (single candidate).
*
* `candidateCount` is passed separately because per-discussion overrides can
* raise it above the deploy-time value.
*/
static shouldUseGenkitClient(
config: ResolvedGenaiChatbotConfig,
candidateCount = config.candidateCount
): boolean {
return !wantsMultipleCandidates({
candidateCount,
candidatesField: config.candidatesField,
});
}

async generateResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { GoogleGenerativeAI, type SafetySetting } from "@google/generative-ai";
import { logger } from "../logger";
import { DiscussionClient, type Message } from "./base_class";
import { answerText } from "./parts";

interface GeminiChatOptions {
history?: Message[];
Expand Down Expand Up @@ -106,7 +107,7 @@ export class GeminiDiscussionClient extends DiscussionClient<
);
});

const text = result.response.text();
const text = answerText(result.response.candidates?.[0]?.content?.parts);

if (!text) {
throw new Error("No text returned candidate");
Expand All @@ -115,8 +116,9 @@ export class GeminiDiscussionClient extends DiscussionClient<
return {
response: text,
candidates:
result.response.candidates?.map((c) => c.content.parts[0].text ?? "") ??
[],
result.response.candidates?.map(
(c) => answerText(c.content.parts) ?? ""
) ?? [],
safetyMetadata: result.response.promptFeedback,
history,
};
Expand Down
13 changes: 8 additions & 5 deletions kits/firestore-genai-chatbot/src/generative-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,20 @@ import { VertexDiscussionClient } from "./vertex_ai";
type Client = Genkit | GoogleGenAI | GoogleGenerativeAI;

/**
* Selects and constructs the generative client for a resolved config. Prefers
* the Genkit client when it can serve the request (single candidate, known
* model), falling back to the provider-specific SDK clients.
* Selects and constructs the generative client for a request. Prefers the
* Genkit client when it can serve the request (single candidate), falling back
* to the provider-specific SDK clients.
*
* @param config - The resolved chatbot configuration.
* @param candidateCount - Effective candidate count for this request, which
* per-discussion overrides can raise above the deploy-time value.
* @returns A ready-to-use discussion client.
*/
export const getGenerativeClient = (
config: ResolvedGenaiChatbotConfig
config: ResolvedGenaiChatbotConfig,
candidateCount = config.candidateCount
): DiscussionClient<Client, any, any> => {
if (GenkitDiscussionClient.shouldUseGenkitClient(config)) {
if (GenkitDiscussionClient.shouldUseGenkitClient(config, candidateCount)) {
return new GenkitDiscussionClient(config);
}

Expand Down
34 changes: 34 additions & 0 deletions kits/firestore-genai-chatbot/src/generative-client/parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/** A response part as far as candidate parsing is concerned. */
export interface TextPart {
text?: string;
thought?: boolean;
}

/**
* First non-thought text part of a candidate.
*
* Reading `parts[0].text` is not reliable for thinking models: they can lead
* with thought parts, or put the answer in a later part. Thought parts carry
* `thought: true`, which the pinned `@google/generative-ai` version does not
* type, so callers pass their own part shape in.
*/
export function answerText(parts?: TextPart[]): string | undefined {
return parts?.find((part) => !part.thought && typeof part.text === "string")
?.text;
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "@google/genai";
import { logger } from "../logger";
import { DiscussionClient, type Message } from "./base_class";
import { answerText } from "./parts";

interface GeminiChatOptions {
history?: Message[];
Expand Down Expand Up @@ -128,7 +129,7 @@ export class VertexDiscussionClient extends DiscussionClient<
}

const candidates = result.candidates
.map((candidate) => candidate.content?.parts?.[0]?.text)
.map((candidate) => answerText(candidate.content?.parts))
.filter((text): text is string => typeof text === "string");

if (candidates.length === 0) {
Expand Down
Loading
Loading