All ArticlesAI & Agents•November 11, 2026•8 min read

The Gemini Decisions API: Architecting a Deterministic Decision Service on a Probabilistic Model

A Hacker News thread argued about whether an LLM belongs on the critical path of a business decision. This is the production reference architecture that settles the argument: schema-bound structured output, bounded tool loops, deterministic policy gates, and a latency budget you can defend in a design review.

The Gemini Decisions API: Architecting a Deterministic Decision Service on a Probabilistic ModelAI Generated with Qwen 3.0 via CometAPI

The Gemini Decisions API: Architecting a Deterministic Decision Service on a Probabilistic Model

There is a familiar arc to model-launch threads on Hacker News: a terse title, a benchmark chart, and four hundred comments arguing about whether an LLM belongs on the critical path of a business decision. The Gemini Decisions API discussion followed that arc precisely. This article is not a hot take on the thread — it is the reference architecture your team needs before you ship anything.

One clarification before we start, because it matters for procurement and for your architecture review: there is no standalone GA product named the "Gemini Decisions API." What the thread described — and what mature teams are actually running in production — is a decision service composed from documented Gemini primitives: structured outputs, function calling, context caching, and the Batch API — wrapped in a deterministic HTTP contract that your risk, compliance, and SRE teams can reason about.

Why a Decision API Is a Different Contract Than a Chat API

A chat endpoint promises plausible continuation. A decision endpoint promises a typed, auditable, reproducible-enough verdict with a bounded failure mode. That difference drives every architectural choice downstream:

  • Output is a schema, not prose. The model is the last step of a pipeline, not the interface.
  • Failure is a first-class state. escalate is a valid, expected outcome — not an exception.
  • Reproducibility is a requirement. Temperature 0, fixed seed, pinned model version, recorded prompt hash.
  • Latency is a budget, not a metric. A decision service usually sits behind a synchronous caller with a hard 2–3s p95.

Treat the LLM as a proposal engine. The service around it decides.

The Reference Architecture: Five Layers

code
Client ──► API Gateway (auth, idempotency, quota)
              │
              ▼
        [1] Decision Contract (JSON Schema / Pydantic / Zod)
              │
              ▼
        [2] Context Assembler (policy pack + record + retrieval)
              │
              ▼
        [3] Gemini Inference (structured output + bounded tools)
              │
              ▼
        [4] Deterministic Policy Gate (confidence, missing fields, rules)
              │
              ▼
        [5] Persistence, Trace, Eval Sampling ──► Audit Store / OTel

Layer 1 — The Decision Contract Is the API

Everything else is an implementation detail. Model the outcome as a closed enum and force evidence to be cited. Zod and Pydantic both work; the point is that the schema is versioned, reviewed, and tested in CI.

python
# decisions/schema.py
from __future__ import annotations

from typing import Literal

from pydantic import BaseModel, Field, model_validator


class Evidence(BaseModel):
    source_id: str = Field(description="Stable ID of a policy clause or source record.")
    quote: str = Field(max_length=280, description="Verbatim span from that source.")
    polarity: Literal["supports", "contradicts"]


class Decision(BaseModel):
    """Wire contract for POST /v1/decisions (schema v3)."""

    outcome: Literal["approve", "escalate", "deny"]
    confidence: float = Field(ge=0.0, le=1.0)
    rationale: str = Field(min_length=20, max_length=1200)
    evidence: list[Evidence] = Field(min_length=1, max_length=8)
    missing_fields: list[str] = Field(default_factory=list)
    requires_human: bool = False

    @model_validator(mode="after")
    def _policy_gate(self) -> "Decision":
        # Deterministic post-processing. The model proposes; the service disposes.
        if self.confidence < 0.72 or self.missing_fields:
            self.requires_human = True
        if self.outcome == "deny" and not any(
            e.polarity == "supports" for e in self.evidence
        ):
            self.outcome = "escalate"
            self.requires_human = True
        return self

Note the validator: it never lets a low-confidence denial escape without human review. That logic belongs in code, not in a prompt.

Layer 2 & 3 — Bounded Inference With Structured Output and Tools

The inference layer assembles context, calls Gemini with response_schema + response_mime_type, and retries only on genuinely transient upstream failures.

python
# decisions/engine.py
import json
import logging

from google import genai
from google.genai import errors as genai_errors
from google.genai import types
from tenacity import (
    retry, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter,
)

from .schema import Decision

log = logging.getLogger("decisions")
client = genai.Client()
MODEL = "gemini-2.5-flash"  # pin explicitly; never use a floating alias in prod

SYSTEM = """You are a credit-limit review engine.
Rules:
1. Every assertion in `rationale` must map to an item in `evidence`.
2. `source_id` must come from the supplied policy pack. Never invent IDs.
3. If a required field is absent, add it to `missing_fields` and set outcome='escalate'.
4. Prefer 'escalate' over guessing. A wrong 'approve' costs 40x a manual review.
"""

RETRYABLE = {429, 500, 502, 503, 504}


class RetryableUpstream(Exception):
    """Normalized transient error from the inference provider."""


@retry(
    retry=retry_if_exception_type(RetryableUpstream),
    wait=wait_exponential_jitter(initial=0.4, max=8.0),
    stop=stop_after_attempt(4),
    reraise=True,
)
def decide(payload: dict, request_id: str) -> Decision:
    try:
        resp = client.models.generate_content(
            model=MODEL,
            contents=render(payload),
            config=types.GenerateContentConfig(
                system_instruction=SYSTEM,
                response_mime_type="application/json",
                response_schema=Decision,
                temperature=0.0,
                seed=1337,
                max_output_tokens=2048,
                thinking_config=types.ThinkingConfig(thinking_budget=0),
            ),
        )
    except genai_errors.APIError as exc:
        if exc.code in RETRYABLE:
            raise RetryableUpstream(f"upstream {exc.code}") from exc
        log.error("non_retryable_upstream", extra={"request_id": request_id,
                                                   "code": exc.code})
        raise

    decision = resp.parsed  # already a validated Decision instance
    if decision is None:
        raise RetryableUpstream(f"unparseable decision for {request_id}")
    log.info("decision_emitted", extra={
        "request_id": request_id,
        "outcome": decision.outcome,
        "confidence": decision.confidence,
        "input_tokens": resp.usage_metadata.prompt_token_count,
        "output_tokens": resp.usage_metadata.candidates_token_count,
    })
    return decision

When the decision legitimately needs lookups, bound the tool loop. Unbounded agentic loops are the number-one cause of p99 explosions in these systems.

python
HANDLERS = {"lookup_policy": lookup_policy, "fetch_account": fetch_account}


def decide_with_tools(payload: dict, max_turns: int = 4) -> Decision:
    config = types.GenerateContentConfig(
        system_instruction=SYSTEM,
        tools=[lookup_policy, fetch_account],
        response_mime_type="application/json",
        response_schema=Decision,
        temperature=0.0,
    )
    contents = [types.Content(role="user", parts=[types.Part(text=render(payload))])]

    for _ in range(max_turns):
        resp = client.models.generate_content(
            model=MODEL, contents=contents, config=config
        )
        calls = resp.function_calls or []
        if not calls:
            if resp.parsed is None:
                raise RetryableUpstream("final turn was not a valid Decision")
            return resp.parsed

        contents.append(resp.candidates[0].content)
        parts = []
        for call in calls:
            handler = HANDLERS.get(call.name or "")
            try:
                if handler is None:
                    raise KeyError(f"unknown_tool:{call.name}")
                result = {"result": handler(**(call.args or {}))}
            except Exception as exc:  # tool errors are data, not crashes
                result = {"error": f"{type(exc).__name__}: {exc}"}
            parts.append(types.Part.from_function_response(
                name=call.name, response=result))
        # Gemini expects function responses delivered in a user-role turn.
        contents.append(types.Content(role="user", parts=parts))

    raise TimeoutError("tool loop exhausted")

Layer 4 — Caching and Batch Are Cost Architecture

Two primitives change the unit economics of a decision service:

  1. Context caching. Policy packs are large and stable. Cache the prefix (system instruction + policy corpus) and pay a reduced rate on reuse. This is where 70–90% of input tokens usually live.
  2. Batch mode. Nightly re-scoring, backfills, and eval suites should never touch the synchronous path. Batch is dramatically cheaper and has no p95 obligation.

Layer 5 — Guardrail: Idempotency, Traces, and Eval Sampling

  • Require an Idempotency-Key header; decisions are money. Retries from a flaky client must not double-approve.
  • Emit one OpenTelemetry span per decision with prompt hash, model version, token counts, outcome, and gate actions.
  • Sample 1–5% of decisions for offline grading against a labelled golden set, and fail CI when agreement drops more than 2 points.
typescript
// decisions/client.ts — typed consumer side
import { z } from "zod";

const Evidence = z.object({
  sourceId: z.string(),
  quote: z.string().max(280),
  polarity: z.enum(["supports", "contradicts"]),
});

export const Decision = z.object({
  outcome: z.enum(["approve", "escalate", "deny"]),
  confidence: z.number().min(0).max(1),
  rationale: z.string().min(20).max(1200),
  evidence: z.array(Evidence).min(1).max(8),
  missingFields: z.array(z.string()),
  requiresHuman: z.boolean(),
});
export type Decision = z.infer<typeof Decision>;

export async function requestDecision(
  input: unknown,
  signal: AbortSignal,
): Promise<Decision> {
  const res = await fetch(`${BASE}/v1/decisions`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${token}`,
      "idempotency-key": crypto.randomUUID(),
    },
    body: JSON.stringify(input),
    signal,
  });

  if (!res.ok) {
    // RFC 9457 problem+json from the gateway
    const problem = await res.json().catch(() => ({}));
    throw new Error(`decision_failed:${res.status}:${problem.title ?? "unknown"}`);
  }

  const parsed = Decision.safeParse(await res.json());
  if (!parsed.success) {
    throw new Error(`contract_violation: ${parsed.error.message}`);
  }
  return parsed.data;
}

Latency and Cost Math You Can Defend

Illustrative budget for one synchronous decision (verify against your own traces):

Stagep50p95Control
Context assembly + retrieval40 ms120 msPrecomputed packs, warm cache
Gemini time-to-first-token380 ms900 msthinking_budget=0, Flash-tier model
Decode (~400 output tokens)600 ms1100 msCap max_output_tokens
Schema validation + policy gate3 ms9 msPure local CPU
Total~1.0 s~2.1 sHard client timeout at 3 s

Cost is a linear function you should compute explicitly rather than guess at:

code
cost_per_1k = 1000 * (
    uncached_in / 1e6 * P_in
  + cached_in   / 1e6 * P_cached
  + output_tok  / 1e6 * P_out
)

With a 3,200-token prompt of which 2,800 are cached, 400 output tokens, and current published rates from the pricing page, most teams land in the sub-cent-per-decision range — cheap enough that the human review step, not the model, dominates the cost model. Optimize the escalation rate before you optimize tokens.

Production Pitfalls Checklist

  • Never use a floating model alias. Pin the version string; run a shadow eval before bumping.
  • Do not retry on 4xx. Retrying a malformed request turns a fast failure into a quota incident.
  • Validate the parsed object, not just the JSON. response_schema constrains generation; your gate still enforces business rules.
  • Cap tool turns. Four is usually generous. Log exhaustion as a distinct error class.
  • Persist the prompt hash. Without it, "why did this change?" is unanswerable during an incident.
  • Make `escalate` cheap. Route to a queue with SLA, not to a support inbox.
  • Separate the eval set from the training examples you put in the system instruction — otherwise your agreement metric is circular.

When Not to Build One

If the decision is expressible as a deterministic rule (a threshold, a lookup, a rate table), ship the rule. An LLM decision layer earns its place only when the inputs are unstructured, the rule set is genuinely too large or too fast-moving to enumerate, and you have a human review path for the tail. If any of those three is missing, you are buying latency and nondeterminism you cannot amortize.

Sources & Technical References

  1. Google AI for Developers — Structured output (response schemas, JSON mode): https://ai.google.dev/gemini-api/docs/structured-output
  2. Google AI for Developers — Function calling (tool declarations, function responses): https://ai.google.dev/gemini-api/docs/function-calling
  3. Google AI for Developers — Context caching: https://ai.google.dev/gemini-api/docs/caching
  4. Google AI for Developers — Batch mode: https://ai.google.dev/gemini-api/docs/batch-mode
  5. Google AI for Developers — Gemini models (versions, context windows, thinking budgets): https://ai.google.dev/gemini-api/docs/models
  6. Google AI for Developers — Pricing: https://ai.google.dev/gemini-api/docs/pricing
  7. Google AI for Developers — Safety guidance and settings: https://ai.google.dev/gemini-api/docs/safety-guidance
  8. Vertex AI — Generative AI on Vertex AI: controlled generation: https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/control-generated-output
  9. JSON Schema Specification, Draft 2020-12 (Core): https://json-schema.org/draft/2020-12/json-schema-core
  10. RFC 9457 — Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc9457
  11. RFC 9110 — HTTP Semantics (idempotency and safe method semantics): https://www.rfc-editor.org/rfc/rfc9110
  12. NIST — AI Risk Management Framework (AI RMF 1.0): https://www.nist.gov/itl/ai-risk-management-framework
  13. OpenTelemetry — Semantic conventions for generative AI systems: https://opentelemetry.io/docs/specs/semconv/gen-ai/
  14. Pydantic v2 Documentation — Models, validators, and JSON Schema generation: https://docs.pydantic.dev/latest/concepts/models/
1 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles