Skip to main content

LLM Gateway for Java Microservices: Run the Code and See the Output

The runnable companion to the Spring AI 2.0 LLM gateway article: clone the module, run the tests with no API key or Docker, read the configuration, and see what the gateway answers over HTTP.

This is the hands-on companion to The LLM Gateway Pattern for Java Microservices. That article explains the design and shows what each test found. This page is for the moment after you have read it: you want to clone the code, run it, change something and watch what happens. Nothing here needs an API key, Docker or a network connection beyond Maven’s own downloads. An earlier version of this page was a long dump of every source file, a Redis container and a set of demo requests with sample responses. The files now live in the repository, where they are compiled and tested, and the sample responses are replaced by real ones captured from a test run. That change is the main point of this rewrite.
Versions, and an honest limit. Spring Boot 4.1.1, Spring AI 2.0.1, Resilience4j 2.4.0, Bucket4j 8.21.0 and Java 25. The module is llm-gateway in asmhatre/spring-ai. I did not call any vendor’s API: the tests use a scripted model or a local server that speaks the OpenAI and Anthropic wire formats. The command that starts a live gateway with real keys is in the README, and I did not run it.

What you need

A JDK 25 and Maven 3.9 or later. That is all. The older version of this page also asked for Docker and Redis; the module keeps its state in memory, so neither is needed, and the trade-off is that breakers, caps and the cache are per instance. The article’s last section says what that means for several replicas.
Going deeper: why Redis is gone
The earlier code stored rate-limit buckets and the cache in Redis. I could not run Redis in the environment where I tested this, and I do not publish behaviour I have not seen, so the module uses Bucket4j and a list in memory instead. Bucket4j has Redis and other backends if you want shared limits, but I have not tested them here, and the breaker state would still be per replica.
  • README.md — the module overview, including the section on what is not covered.
  • The article — the “what is not here” box.

Run it in one command

Clone the repository and run the script. It deletes target/, runs every test and leaves the results in the output/ directory. The tests are the demos: each one writes a transcript with the numbers it just asserted, so a transcript that is out of date would fail the build. The script is run-all.sh.
git clone https://ankurm.com/git.app/asmhatre/spring-ai.git
cd spring-ai/llm-gateway
scripts/run-all.sh
Two consecutive runs produce byte-identical files, so a change in output/ after you edit the code is a real change in behaviour, and the diff is the review.

Where things are

There are eight classes you need to know, and each does one job. The first column links to the file; the article section that explains it is named in the last column.
FileJobArticle section
Gateway.javaToken limit, cache, the ChatClient with the tool advisor, meteringA gateway is one door
Failover.javaA ChatModel that tries providers in order, with a breaker and a budget hold per attemptThe tool loop is above the model
Routes.javaHint to provider chain; an unknown hint is an errorRoute by a hint
Failures.javaHTTP status out of the vendor SDK exceptionsFail over for failures another provider can fix
Budgets.javaDollar caps: reserve before the call, settle afterCaps
TokenLimiter.javaTokens per hour per tenantCaps
SemanticCache.javaEmbedding cache keyed by tenant and featureA semantic cache
GatewayController.javaThe HTTP endpoint and its status codesWhat the caller sees
Providers, prices, routes and tenant limits are not code. They are the configuration file application.yml, which is also the file you edit to try your own setup.

The configuration is the product

A route is a list of provider names, in the order they are tried. A tenant is a name with a token limit and a dollar budget, and the defaults apply to any tenant not listed. This is the whole file:
# Providers are plain data. Prices are dollars per million tokens (read 2026-10-09, see CostTest in the providers module).
gateway:
  providers:
    - { name: openai, type: openai, base-url: "https://api.openai.com/v1", api-key: "${OPENAI_API_KEY:unset}", model: gpt-6.1-sol, input-price: "2.00", output-price: "10.00" }
    - { name: anthropic, type: anthropic, base-url: "https://api.anthropic.com", api-key: "${ANTHROPIC_API_KEY:unset}", model: claude-sonnet-5-5, input-price: "2.00", output-price: "10.00" }
  routes:
    smart: [openai, anthropic]
    fast: [anthropic, openai]
  defaults: { budget-micros: 5000000, tokens-per-hour: 100000 }
  tenants:
    free-tier-tenant: { budget-micros: 50000, tokens-per-hour: 10000 }
  breaker: { sliding-window-size: 10, minimum-calls: 5, failure-rate-threshold: 50, wait-open-millis: 30000 }
Three details are worth knowing before you change it. The OpenAI base-url must end in /v1; without it the request goes to a path that does not exist, and the failure looks like a provider outage. Prices are dollars per million tokens. And minimum-calls is set on purpose: Resilience4j’s default is 100, which silently stops a window of 10 from opening until the eleventh request (see 03-circuit-breaker.txt).
Going deeper: the dependencies
The pom.xml is short. Spring Boot 4.1.1 is the parent; the Spring AI BOM manages the three Spring AI artifacts (spring-ai-client-chat, spring-ai-openai, spring-ai-anthropic); Resilience4j (resilience4j-circuitbreaker, used programmatically, no annotations) and Bucket4j (bucket4j_jdk17-core) are pinned by properties. There is no Redis, Micrometer or actuator dependency.

What the gateway answers

This is the output of the HTTP test, 10-http.txt, written by HttpTest.java. It starts the real application on a random port, with the real OpenAI and Anthropic model classes pointed at a local fake. Each line is one request:
1. normal call            -> 200 {"content":"30 days.","provider":"openai","model":"gpt-6.1-sol","promptTokens":47,"completionTokens":52,"costMicros":614,"modelCalls":1,"servedFromCache":false,"trail":["openai: ok"]}
2. openai returns 503     -> 200 {"content":"30 days.","provider":"anthropic","model":"claude-sonnet-5-5","promptTokens":47,"completionTokens":52,"costMicros":614,"modelCalls":1,"servedFromCache":false,"trail":["openai: failed with status 503","anthropic: ok"]}
3. both providers down    -> 503 Retry-After=30 No provider could answer: [openai: failed with status 503, anthropic: failed with status 529]
4. unknown hint           -> 400 Unknown model hint 'locla'. Known hints: [fast, smart]
5. tool not allow-listed  -> 400 Tool 'drop_tables' is not on the gateway allow-list [refund_order]
6. tenant over its cap    -> 429 Budget for poor would be exceeded: spent 0 of 1000 microdollars, this call may cost up to 2574
7. body claims tenant acme, header says poor -> 429 (the header wins)
8. no X-Tenant-Id header  -> 400
Read it as the contract. A normal call returns the answer, the provider and model that produced it, the token counts, the cost in microdollars (one millionth of a dollar), and a trail of what was tried. Case 2 is a failover: the first provider returned 503, and the answer came from the second. Case 3 is every provider down: a 503 with a Retry-After header. Cases 4 and 5 are requests that name something that does not exist, and case 6 is a tenant over its cap; in all three no provider is called. The header decides the tenant (case 7), and a missing header is a 400 (case 8). The older page showed a cache hit with a latency of 41 ms against 1,240 ms for a normal call, and a Prometheus metric. Those numbers came from no run I can reproduce, so they are gone. The cache is exercised in its own test instead, and the gateway returns cost in the response rather than as a metric.
Going deeper: trying it by hand
With real keys, mvn spring-boot:run starts the gateway on port 8080, and the request below sends the first case from the transcript. It needs OPENAI_API_KEY and ANTHROPIC_API_KEY in the environment. I did not run this against real providers; the same request through the test uses the local fake.
OPENAI_API_KEY=... ANTHROPIC_API_KEY=... mvn spring-boot:run

curl -X POST localhost:8080/v1/gateway/complete \
  -H 'X-Tenant-Id: acme' -H 'Content-Type: application/json' \
  -d '{"featureTag":"support","userMessage":"What is your return policy?","modelHint":"smart","maxTokens":256}'
This command is the Quickstart in the module README.md. The gateway trusts the X-Tenant-Id header, so put it behind something that sets the header and do not expose it publicly.
  • README.md — quickstart, versions and the index of output files.
  • HttpTest.java — how the fake vendors are wired in.

Which transcript answers which question

Each file in output/ is the evidence for one claim in the article. If you only want one answer, open that file.
QuestionTranscript
Does a typo in the hint go to a cloud provider?01-routing.txt
Which statuses fail over, and which stop?02-failover.txt
When does the breaker really open?03-circuit-breaker.txt
How many times does a tool run when failover happens mid loop?04-tool-loop.txt
What does the second provider receive?05-wire.txt
What do the vendor SDKs throw?06-real-exceptions.txt
What does a cap do to a request and to a tool loop?07-budget.txt
Does a cap hold under 64 simultaneous requests?07b-budget-concurrency.txt
How is the token limit settled and refilled?08-rate-limit.txt
Does a near-identical question hit the cache?09-cache.txt

Should you even do this?

Run it, read it, then decide. This module is a teaching and testing vehicle, not a drop-in service. It has no authentication, no streaming and no shared state, it was never run against a real provider, and its semantic cache was tested with a toy embedding model. If you have one service and one provider, the article explains why a shared library is a better start than a service.
Frequently asked questions

Can I run the tests without Docker or API keys?

Yes. Nothing in the module needs either. The only network use is Maven downloading dependencies on the first build.

Where did the Redis, Kubernetes and Prometheus parts go?

They were in the earlier version and are not in this one, because I could not run them and will not publish unverified setup. The article lists them under what it did not test.

How do I add a provider?

Add an entry under gateway.providers with its type, URL, key, model and prices, then add its name to a route. The two types the module understands are openai and anthropic; anything else needs a new branch in GatewayConfig.java.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.