Integrating Google Gemini
Large Language Models (LLMs) have transformed artificial intelligence by providing natural language processing, coding assistance, and reasoning capabilities. Google’s Gemini models are highly capable models accessible via a developer-friendly REST API.
In this chapter, we implement a Google Gemini REST API client in Scala 3 using the lightweight requests HTTP library and the ujson library for JSON serialization. We also show how to enable Google Search Grounding to let Gemini query Google Search to answer real-time questions.
All code is in source-code/gemini-client.
How Large Language Models Work
To use an LLM well it helps to know what one is. Modern LLMs are transformer networks, the architecture introduced by Vaswani and colleagues in the 2017 paper “Attention Is All You Need”. The transformer’s core mechanism is self-attention, which lets the model weigh how much each word in the input relates to every other word, so it can track meaning across long passages. This is a direct descendant of the small neural network we built by hand earlier in the book, scaled up by many orders of magnitude and trained on a large fraction of the public internet.
An LLM is trained on one deceptively simple task: predict the next token. A token is a chunk of text, usually a common word or a word fragment, produced by the kind of subword tokenizer mentioned in the NLP chapter. Given a sequence of tokens, the model outputs a probability distribution over the next token. To generate text it works autoregressively: it predicts one token, appends it to the input, predicts the next, and repeats. A setting called temperature controls how the next token is drawn from the distribution: near zero the model almost always takes the most likely token and answers deterministically, while higher values sample more freely and produce more varied, creative text.
Two more ideas explain the behavior you will see. The amount of text a model can consider at once is its context window, measured in tokens; everything the model knows about your request must fit inside it. And a raw model trained only to predict text is not yet helpful, so providers add instruction tuning and reinforcement learning from human feedback to teach it to follow instructions and answer politely. The Gemini model we call has already been through all of this; our job is only to send it tokens and read back what it generates.
Project Setup and Dependencies
Instead of using a heavy SDK, we build our client directly on top of Gemini’s HTTP POST endpoints. This keeps the moving parts visible: an LLM API, at bottom, is just an HTTP endpoint that accepts a JSON request and returns a JSON response. We declare our dependencies at the top of gemini-client/GeminiClient.scala using scala-cli directive syntax:
1 //> using scala 3.6.4
2 //> using dep com.lihaoyi::requests:0.9.3
3 //> using dep com.lihaoyi::ujson:4.4.3
requests is a small Scala HTTP client and ujson is a small JSON library, both from the lihaoyi ecosystem. Together they are all we need to talk to any REST-based LLM, which is why the OpenAI and Ollama clients in the next two chapters reuse the same two dependencies.
Implementing the Gemini Client
The client connects to the generativelanguage.googleapis.com API. It reads the API key from the GOOGLE_API_KEY environment variable, which is the standard way to keep secrets out of source code.
We define two completion methods: getCompletion for standard prompts, and getCompletionWithSearch which instructs the model to ground its response using live Google Search results:
1 package gemini_client
2
3 import java.io.IOException
4
5 object GeminiClient:
6 private val DEFAULT_MODEL = "gemini-2.5-flash"
7 private val API_HOST = "generativelanguage.googleapis.com"
8
9 def getCompletion(prompt: String, model: String = DEFAULT_MODEL): String =
10 val apiKey = getApiKey()
11 val url = s"https://$API_HOST/v1beta/models/$model:generateContent?key=$apiKey"
12
13 val payload = ujson.Obj(
14 "contents" -> ujson.Arr(
15 ujson.Obj(
16 "parts" -> ujson.Arr(
17 ujson.Obj("text" -> prompt)
18 )
19 )
20 )
21 ).render()
22
23 val response = requests.post(
24 url = url,
25 headers = Map("Content-Type" -> "application/json"),
26 data = payload
27 )
28
29 if response.statusCode != 200 then
30 throw IOException(s"Gemini request failed: ${response.text()}")
31
32 parseResponse(response.text())
33
34 def getCompletionWithSearch(prompt: String, model: String = DEFAULT_MODEL): String =
35 val apiKey = getApiKey()
36 val url = s"https://$API_HOST/v1beta/models/$model:generateContent?key=$apiKey"
37
38 val payload = ujson.Obj(
39 "contents" -> ujson.Arr(
40 ujson.Obj(
41 "parts" -> ujson.Arr(
42 ujson.Obj("text" -> prompt)
43 )
44 )
45 ),
46 "tools" -> ujson.Arr(
47 ujson.Obj("google_search" -> ujson.Obj()) // Enable Google Search Grounding
48 )
49 ).render()
50
51 val response = requests.post(
52 url = url,
53 headers = Map("Content-Type" -> "application/json"),
54 data = payload
55 )
56
57 if response.statusCode != 200 then
58 throw IOException(s"Gemini request failed: ${response.text()}")
59
60 parseResponse(response.text())
61
62 private def getApiKey(): String =
63 val key = System.getenv("GOOGLE_API_KEY")
64 if key == null || key.trim.isEmpty then
65 throw IOException("GOOGLE_API_KEY environment variable is not set.")
66 key.trim
67
68 private def parseResponse(jsonStr: String): String =
69 val data = ujson.read(jsonStr)
70 data("candidates")(0)("content")("parts")(0)("text").str
The request body shows the shape of Gemini’s API. A request is a list of contents, each holding a list of parts, and each part carries a piece of text. This nesting looks heavy for a single text prompt, but it exists because Gemini is multimodal: a part can hold an image or audio clip as easily as text, so the same structure serves every input type. The response mirrors it. The model returns a list of candidates (alternative completions), and we reach into the first candidate’s content to pull out the generated text with data("candidates")(0)("content")("parts")(0)("text").str. Note also that the call is stateless: the API remembers nothing between requests, so a multi-turn conversation must resend the whole history each time. We check the HTTP status and raise an IOException on anything but 200, because API calls fail for real reasons: a bad key, a rate limit, or a network drop.
Grounding with Google Search
The second method adds a tools array containing google_search, and this small change addresses one of the biggest weaknesses of LLMs. A model’s knowledge is frozen at its training cutoff and it can state false things fluently, a failure called hallucination. Grounding fixes both: the model runs a live Google Search, reads the results, and bases its answer on them instead of on memory alone. This is the same retrieve-then-generate idea behind Retrieval-Augmented Generation (RAG) and the same pattern as the Knowledge Graph Navigator, where we fetched facts from DBpedia before answering. Turning it on is as simple as declaring the tool; Gemini decides when to search and folds the results into its reply.
Running the Demos
In gemini-client/Main.scala, we run a standard prompt explaining Scala concepts, and a real-time news query grounded in Google Search. The contrast between the two calls is the point: the first tests the model’s trained knowledge, the second tests its ability to fetch current facts:
1 package gemini_client
2
3 @main def geminiDemo(): Unit =
4 println("=" * 50)
5 println("Gemini LLM Client Demo")
6 println("=" * 50)
7
8 val prompt1 = "Briefly explain the concept of pattern matching in Scala."
9 println(s"\nPrompt: $prompt1\n")
10 try
11 val completion1 = GeminiClient.getCompletion(prompt1)
12 println(s"Response:\n$completion1")
13 catch
14 case e: Exception => println(s"Error: ${e.getMessage}")
15
16 val prompt2 = "What are the latest news updates on space exploration from this week?"
17 println(s"\nPrompt with Google Search Grounding: $prompt2\n")
18 try
19 val completion2 = GeminiClient.getCompletionWithSearch(prompt2)
20 println(s"Response:\n$completion2")
21 catch
22 case e: Exception => println(s"Error: ${e.getMessage}")
Export your API key and launch the project:
1 export GOOGLE_API_KEY="your-api-key-here"
2 scala-cli run .
This will run the demo, printing Gemini’s responses:
1 ==================================================
2 Gemini LLM Client Demo
3 ==================================================
4
5 Prompt: Briefly explain the concept of pattern matching in Scala.
6
7 Response:
8 Pattern matching in Scala is a powerful mechanism for checking a value against a pattern. It's like a type-safe and more expressive version of the `switch` statement found in Java or C++.
9 You use the `match` keyword followed by one or more `case` clauses. It can match constants, variable patterns, case classes, types, and extract values using extractor objects...
10
11 Prompt with Google Search Grounding: What are the latest news updates on space exploration from this week?
12
13 Response:
14 According to recent reports on space exploration this week:
15 1. NASA's James Webb Space Telescope detected a new super-Earth atmosphere...
16 2. SpaceX successfully launched its 11th Starlink batch of the month and prepared for the next Starship flight test...
The two responses come from the same model through the same code, yet they draw on different sources. The first answer about pattern matching comes straight from the model’s trained weights, knowledge baked in during pretraining. The second answer about this week’s launches could not possibly live in the weights, since the events happened after training; it comes from the live search results the grounding tool fetched. That difference, between what a model knows and what it can look up, is the single most important idea for building reliable LLM applications, and the rest of the LLM chapters build on it.