getJsonObjectFromSteam(InputStream obj) {
+ try {
+ var node = om.readTree(obj);
+ if (node instanceof ObjectNode on) {
+ return Optional.of(on);
+ }
+ log.debug("Expected JSON Object but got: {}",node.getNodeType());
+ } catch (Exception e) {
+ log.debug("Error reading JSON: {}", e.getMessage());
+ }
+ return empty();
+ }
+
+
+ public static void setJsonBody(Message msg, ObjectNode json) {
+ try {
+ if (!msg.isJSON()) {
+ msg.getHeader().setContentType(APPLICATION_JSON);
+ }
+ msg.setBodyContent(om.writeValueAsBytes(json));
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException(e);
+ }
+ }
}
diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/mcp/MembraneMCPServerTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/mcp/MembraneMCPServerTest.java
index 33fa0184f4..b52986e458 100644
--- a/core/src/test/java/com/predic8/membrane/core/interceptor/mcp/MembraneMCPServerTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/interceptor/mcp/MembraneMCPServerTest.java
@@ -1,3 +1,17 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
package com.predic8.membrane.core.interceptor.mcp;
import com.fasterxml.jackson.databind.JsonNode;
diff --git a/core/src/test/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequestTest.java b/core/src/test/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequestTest.java
index 935ae66c12..f5efd94d6e 100644
--- a/core/src/test/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequestTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequestTest.java
@@ -1,3 +1,17 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
package com.predic8.membrane.core.jsonrpc;
import org.junit.jupiter.api.Test;
diff --git a/core/src/test/java/com/predic8/membrane/core/jsonrpc/JSONRPCResponseTest.java b/core/src/test/java/com/predic8/membrane/core/jsonrpc/JSONRPCResponseTest.java
index 97eed97b58..7c0a85e98c 100644
--- a/core/src/test/java/com/predic8/membrane/core/jsonrpc/JSONRPCResponseTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/jsonrpc/JSONRPCResponseTest.java
@@ -1,3 +1,17 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
package com.predic8.membrane.core.jsonrpc;
import org.junit.jupiter.api.Test;
diff --git a/core/src/test/java/com/predic8/membrane/core/mcp/MCPInitializeTest.java b/core/src/test/java/com/predic8/membrane/core/mcp/MCPInitializeTest.java
index 02d6dadce4..0abbb75f66 100644
--- a/core/src/test/java/com/predic8/membrane/core/mcp/MCPInitializeTest.java
+++ b/core/src/test/java/com/predic8/membrane/core/mcp/MCPInitializeTest.java
@@ -1,3 +1,17 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
package com.predic8.membrane.core.mcp;
import com.predic8.membrane.core.jsonrpc.JSONRPCRequest;
diff --git a/core/src/test/java/com/predic8/membrane/core/util/http/SSEParserTest.java b/core/src/test/java/com/predic8/membrane/core/util/http/SSEParserTest.java
new file mode 100644
index 0000000000..7738321f85
--- /dev/null
+++ b/core/src/test/java/com/predic8/membrane/core/util/http/SSEParserTest.java
@@ -0,0 +1,165 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
+package com.predic8.membrane.core.util.http;
+
+import com.predic8.membrane.core.http.Chunk;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SSEParserTest {
+
+ @Test
+ void parsesSingleEvent() {
+ var parser = new SSEParser(Set.of("done"));
+
+ assertFalse(parser.parse(chunk("""
+ event: message
+ data: hello
+
+ """)));
+
+ var events = parser.getEvents();
+
+ assertEquals(1, events.size());
+ assertEquals("message", events.getFirst().name());
+ assertEquals("hello", events.getFirst().data());
+ assertTrue(parser.getTerminalEvent().isEmpty());
+ }
+
+ @Test
+ void parsesMultilineData() {
+ var parser = new SSEParser(Set.of("done"));
+
+ parser.parse(chunk("""
+ event: message
+ data: first
+ data: second
+
+ """));
+
+ assertEquals("first\nsecond", parser.getEvents().getFirst().data());
+ }
+
+ @Test
+ void parsesEventSplitAcrossChunks() {
+ var parser = new SSEParser(Set.of("done"));
+
+ assertFalse(parser.parse(chunk("""
+ event: mes""")));
+
+ assertFalse(parser.parse(chunk("""
+ sage
+ data: hel""")));
+
+ assertFalse(parser.parse(chunk("""
+ lo
+
+ """)));
+
+ var event = parser.getEvents().getFirst();
+
+ assertEquals("message", event.name());
+ assertEquals("hello", event.data());
+ }
+
+ @Test
+ void returnsTrueWhenTerminalEventIsFound() {
+ var parser = new SSEParser(Set.of("done"));
+
+ assertTrue(parser.parse(chunk("""
+ event: done
+ data: {"usage":{"total_tokens":42}}
+
+ """)));
+
+ var terminal = parser.getTerminalEvent();
+
+ assertTrue(terminal.isPresent());
+ assertEquals("done", terminal.get().name());
+ assertEquals("{\"usage\":{\"total_tokens\":42}}", terminal.get().data());
+ }
+
+ @Test
+ void ignoresChunksAfterTerminalEvent() {
+ var parser = new SSEParser(Set.of("done"));
+
+ assertTrue(parser.parse(chunk("""
+ event: done
+ data: final
+
+ """)));
+
+ assertTrue(parser.parse(chunk("""
+ event: message
+ data: ignored
+
+ """)));
+
+ assertEquals(1, parser.getEvents().size());
+ assertEquals("done", parser.getEvents().getFirst().name());
+ }
+
+ @Test
+ void ignoresCommentsAndUnknownFields() {
+ var parser = new SSEParser(Set.of("done"));
+
+ parser.parse(chunk("""
+ : comment
+ id: 123
+ retry: 1000
+ event: message
+ data: hello
+
+ """));
+
+ var event = parser.getEvents().getFirst();
+
+ assertEquals("message", event.name());
+ assertEquals("hello", event.data());
+ }
+
+ @Test
+ void supportsCrLfLineEndings() {
+ var parser = new SSEParser(Set.of("done"));
+
+ parser.parse(chunk("event: message\r\ndata: hello\r\n\r\n"));
+
+ var event = parser.getEvents().getFirst();
+
+ assertEquals("message", event.name());
+ assertEquals("hello", event.data());
+ }
+
+ @Test
+ void returnsUnmodifiableEventsList() {
+ var parser = new SSEParser(Set.of("done"));
+
+ parser.parse(chunk("""
+ event: message
+ data: hello
+
+ """));
+
+ assertThrows(UnsupportedOperationException.class,
+ () -> parser.getEvents().add(new SSEParser.SSEEvent("x", "y")));
+ }
+
+ private static Chunk chunk(String content) {
+ return new Chunk(content.getBytes());
+ }
+}
\ No newline at end of file
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/AbstractAiTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/AbstractAiTutorialTest.java
new file mode 100644
index 0000000000..77c674ee1e
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/AbstractAiTutorialTest.java
@@ -0,0 +1,169 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
+package com.predic8.membrane.tutorials.ai.llmgateway;
+
+import com.predic8.membrane.core.exchange.Exchange;
+import com.predic8.membrane.core.interceptor.AbstractInterceptor;
+import com.predic8.membrane.core.interceptor.Outcome;
+import com.predic8.membrane.core.interceptor.flow.ReturnInterceptor;
+import com.predic8.membrane.core.interceptor.templating.StaticInterceptor;
+import com.predic8.membrane.core.proxies.ServiceProxy;
+import com.predic8.membrane.core.proxies.ServiceProxyKey;
+import com.predic8.membrane.core.router.DefaultRouter;
+import com.predic8.membrane.examples.util.DistributionExtractingTestcase;
+import com.predic8.membrane.examples.util.Process2;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+import java.util.function.Consumer;
+
+import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON;
+
+/**
+ * Base class for AI tutorial tests. Starts a local Membrane mock of the upstream LLM API
+ * so tests run without a real API key and without network access to the LLM provider.
+ *
+ * The tutorial YAML's {@code target.url} is rewritten to point at the mock server
+ * before Membrane starts. Subclasses override {@link #getTutorialDir()} and
+ * {@link #getTutorialYaml()} to select the tutorial under test.
+ *
+ *
JUnit 5 lifecycle ordering guarantees that {@code DistributionExtractingTestcase.init()}
+ * (superclass {@code @BeforeEach}) runs first and sets {@code baseDir}, allowing
+ * {@link #startGateway()} to use {@code replaceInFile2()} safely.
+ */
+public abstract class AbstractAiTutorialTest extends DistributionExtractingTestcase {
+
+ protected static final int MOCK_LLM_PORT = 3100;
+
+ /**
+ * Value substituted for the {@code <>} placeholder in tutorial
+ * YAMLs before Membrane starts. Tests that verify upstream key-substitution assert against
+ * this constant instead of the raw placeholder text.
+ */
+ protected static final String TEST_API_KEY = "test-upstream-key";
+
+ protected Process2 process;
+ protected volatile String lastRequestBody;
+ protected volatile String lastRequestApiKey;
+
+ private DefaultRouter mockRouter;
+
+ protected abstract String getTutorialDir();
+ protected abstract String getTutorialYaml();
+
+ @Override
+ protected String getExampleDirName() {
+ return "../tutorials/%s".formatted(getTutorialDir());
+ }
+
+ @Override
+ protected String getParameters() {
+ return "-c %s".formatted(getTutorialYaml());
+ }
+
+ /**
+ * Runs after {@code DistributionExtractingTestcase.init()} sets {@code baseDir}.
+ * Starts the mock, patches the YAML, then starts Membrane.
+ */
+ @BeforeEach
+ void startGateway() throws Exception {
+ startMockLlmApi();
+ replaceInFile2(getTutorialYaml(), getUpstreamApiUrl(), mockApiUrl());
+ replaceInFile2(getTutorialYaml(), "<>", TEST_API_KEY);
+ process = startServiceProxyScript();
+ }
+
+ @AfterEach
+ void stopGateway() {
+ if (process != null)
+ process.killScript();
+ if (mockRouter != null)
+ mockRouter.stop();
+ }
+
+ /**
+ * The upstream API URL used in the tutorial YAML (to be replaced by the mock URL).
+ */
+ protected String getUpstreamApiUrl() {
+ return "https://api.anthropic.com";
+ }
+
+ protected String mockApiUrl() {
+ return "http://localhost:" + MOCK_LLM_PORT;
+ }
+
+ /**
+ * The HTTP header name from which the upstream API key is read when capturing
+ * requests in the mock. Defaults to {@code "x-api-key"} (Claude). Override to
+ * {@code "authorization"} for OpenAI or {@code "x-goog-api-key"} for Google.
+ */
+ protected String getApiKeyHeader() {
+ return "x-api-key";
+ }
+
+ /**
+ * Content-Type the mock LLM server sends back. Defaults to {@code "application/json"}
+ * for regular responses. Override to {@code "text/event-stream"} in streaming test classes.
+ */
+ protected String mockContentType() {
+ return APPLICATION_JSON;
+ }
+
+ private void startMockLlmApi() throws Exception {
+ var si = new StaticInterceptor();
+ si.setSrc(mockResponse());
+ si.setContentType(mockContentType());
+
+ var sp = new ServiceProxy(new ServiceProxyKey(MOCK_LLM_PORT), null, 0);
+ sp.getFlow().add(new BodyCaptureInterceptor(
+ body -> lastRequestBody = body,
+ apiKey -> lastRequestApiKey = apiKey,
+ getApiKeyHeader()));
+ sp.getFlow().add(si);
+ sp.getFlow().add(new ReturnInterceptor());
+
+ mockRouter = new DefaultRouter();
+ mockRouter.add(sp);
+ mockRouter.start();
+ }
+
+ private static class BodyCaptureInterceptor extends AbstractInterceptor {
+
+ private final Consumer bodySink;
+ private final Consumer apiKeySink;
+ private final String apiKeyHeader;
+
+ BodyCaptureInterceptor(Consumer bodySink, Consumer apiKeySink, String apiKeyHeader) {
+ this.bodySink = bodySink;
+ this.apiKeySink = apiKeySink;
+ this.apiKeyHeader = apiKeyHeader;
+ }
+
+ @Override
+ public Outcome handleRequest(Exchange exc) {
+ bodySink.accept(exc.getRequest().getBodyAsStringDecoded());
+ apiKeySink.accept(exc.getRequest().getHeader().getFirstValue(apiKeyHeader));
+ return Outcome.CONTINUE;
+ }
+ }
+
+ protected String mockResponse() {
+ return """
+ {"id":"msg_mock","type":"message","role":"assistant",\
+ "content":[{"type":"text","text":"I am a mock."}],\
+ "model":"claude-sonnet-4-0","stop_reason":"end_turn",\
+ "usage":{"input_tokens":10,"output_tokens":5}}""";
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/claude/BasicClaudeLLMGatewayTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/claude/BasicClaudeLLMGatewayTutorialTest.java
new file mode 100644
index 0000000000..3cde3fa976
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/claude/BasicClaudeLLMGatewayTutorialTest.java
@@ -0,0 +1,114 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
+package com.predic8.membrane.tutorials.ai.llmgateway.claude;
+
+import com.predic8.membrane.tutorials.ai.llmgateway.AbstractAiTutorialTest;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static io.restassured.RestAssured.given;
+import static io.restassured.path.json.JsonPath.from;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+
+/**
+ * Integration test for {@code distribution/tutorials/ai/llm-gateway/claude/10-Basic-LLM-Gateway.yaml}.
+ *
+ * The tutorial configures a Claude LLM gateway with:
+ *
+ * - {@code maxInputTokens: 100} — requests whose estimated input exceeds 100 tokens are rejected
+ * - {@code maxOutputTokens: 200} — {@code max_tokens} in the forwarded request is capped to 200
+ *
+ *
+ * The upstream Anthropic API is replaced by a local mock server so no real API key is needed.
+ */
+public class BasicClaudeLLMGatewayTutorialTest extends AbstractAiTutorialTest {
+
+ @Override
+ protected String getTutorialDir() {
+ return "ai/llm-gateway/claude";
+ }
+
+ @Override
+ protected String getTutorialYaml() {
+ return "10-Basic-LLM-Gateway.yaml";
+ }
+
+ /**
+ * A request within the token limits is forwarded to the upstream and its response is returned.
+ */
+ @Test
+ void simpleRequestIsForwarded() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-api-key", "test-key")
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(200)
+ .body("type", equalTo("message"))
+ .body("content[0].type", equalTo("text"));
+ // @formatter:on
+ }
+
+ /**
+ * A request whose message content exceeds {@code maxInputTokens} (100) is rejected by the
+ * gateway before reaching the upstream. The response uses the Claude error format.
+ */
+ @Test
+ void inputTokenLimitExceededIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-api-key", "test-key")
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("max-input.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(400)
+ .body("type", equalTo("error"))
+ .body("error.type", equalTo("invalid_request_error"))
+ .body("error.message", containsString("tokens"));
+ // @formatter:on
+ }
+
+ /**
+ * When the request asks for more output tokens than {@code maxOutputTokens} (200) allows,
+ * the gateway rewrites {@code max_tokens} to 200 before forwarding to the upstream.
+ * The mock captures the forwarded body so we can verify the value was actually capped.
+ */
+ @Test
+ void outputTokensAreCappedBeforeForwarding() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-api-key", "test-key")
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("max-output.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(200);
+ // @formatter:on
+
+ assertThat(from(lastRequestBody).getInt("max_tokens"), equalTo(200));
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/claude/SharingApiKeysTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/claude/SharingApiKeysTutorialTest.java
new file mode 100644
index 0000000000..3514870774
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/claude/SharingApiKeysTutorialTest.java
@@ -0,0 +1,223 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
+package com.predic8.membrane.tutorials.ai.llmgateway.claude;
+
+import com.predic8.membrane.tutorials.ai.llmgateway.AbstractAiTutorialTest;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+
+/**
+ * Integration tests for
+ * {@code distribution/tutorials/ai/llm-gateway/claude/20-Sharing-API-Keys.yaml}.
+ *
+ *
The tutorial demonstrates sharing a single upstream API key between multiple users,
+ * each identified by their own gateway key and subject to individual token budgets:
+ *
+ * - alice — key {@code abc123}, budget 250 tokens
+ * - bob — key {@code qwertz}, budget 10 000 tokens
+ *
+ * Additional gateway limits: {@code maxInputTokens=100}, {@code maxOutputTokens=200},
+ * allowed models: {@code claude-sonnet-4-0}, {@code claude-opus-4-0}, {@code claude-haiku-3-5}.
+ */
+public class SharingApiKeysTutorialTest extends AbstractAiTutorialTest {
+
+ private static final String ALICE = "abc123";
+ private static final String BOB = "qwertz";
+
+ @Override
+ protected String getTutorialDir() {
+ return "ai/llm-gateway/claude";
+ }
+
+ @Override
+ protected String getTutorialYaml() {
+ return "20-Sharing-API-Keys.yaml";
+ }
+
+ @Test
+ void aliceCanSendRequest() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-api-key", ALICE)
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(200)
+ .body("type", equalTo("message"));
+ // @formatter:on
+ }
+
+ @Test
+ void bobCanSendRequest() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-api-key", BOB)
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(200)
+ .body("type", equalTo("message"));
+ // @formatter:on
+ }
+
+ @Test
+ void unknownApiKeyIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-api-key", "invalid-key")
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(401)
+ .body("type", equalTo("error"))
+ .body("error.type", equalTo("authentication_error"));
+ // @formatter:on
+ }
+
+ /**
+ * The gateway is configured with its own upstream {@code apiKey}. When a user request
+ * arrives carrying the user-facing key (e.g. alice's {@code abc123}), the gateway must
+ * replace it with the configured upstream key before forwarding to the LLM provider.
+ */
+ @Test
+ void userApiKeyIsReplacedWithGatewayApiKey() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-api-key", ALICE)
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(200);
+ // @formatter:on
+
+ assertThat(lastRequestApiKey, not(equalTo(ALICE)));
+ assertThat(lastRequestApiKey, equalTo(TEST_API_KEY));
+ }
+
+ @Test
+ void wrongModelIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-api-key", ALICE)
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("wrong-model.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(400)
+ .body("type", equalTo("error"))
+ .body("error.type", equalTo("invalid_request_error"))
+ .body("error.message", containsString("gpt-5"))
+ .body("error.message", containsString("not allowed"));
+ // @formatter:on
+ }
+
+ @Test
+ void inputTokenLimitExceededIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-api-key", ALICE)
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("max-input.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(400)
+ .body("type", equalTo("error"))
+ .body("error.type", equalTo("invalid_request_error"))
+ .body("error.message", containsString("prompt is too long"))
+ .body("error.message", containsString("100 maximum"));
+ // @formatter:on
+ }
+
+ /**
+ * Alice has a budget of 250 tokens. Each request with {@code max-output.json} projects
+ * 7 (input estimate) + 200 (capped max_tokens) = 207 tokens. The mock returns 15 tokens
+ * of actual usage per call, so the running total grows by 15 after each response.
+ *
+ * Budget accounting per request:
+ *
+ * 1st: 250 - 0 - 207 = 43 → forwarded; used becomes 15
+ * 2nd: 250 - 15 - 207 = 28 → forwarded; used becomes 30
+ * 3rd: 250 - 30 - 207 = 13 → forwarded; used becomes 45
+ * 4th: 250 - 45 - 207 = -2 → rejected with 429
+ *
+ *
+ * Bob's separate budget of 10 000 tokens is unaffected, so he can still send requests
+ * after alice is blocked.
+ */
+ @Test
+ void alicesTokenBudgetIsExhaustedWhileBobIsUnaffected() throws IOException {
+ for (int i = 0; i < 3; i++) {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-api-key", ALICE)
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("max-output.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(200);
+ // @formatter:on
+ }
+
+ // Alice's budget is now exhausted
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-api-key", ALICE)
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("max-output.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(429)
+ .body("type", equalTo("error"))
+ .body("error.type", equalTo("rate_limit_error"));
+
+ // Bob's budget is independent — he can still send requests
+ given()
+ .contentType("application/json")
+ .header("x-api-key", BOB)
+ .header("anthropic-version", "2023-06-01")
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/messages")
+ .then()
+ .statusCode(200)
+ .body("type", equalTo("message"));
+ // @formatter:on
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/google/AbstractGoogleTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/google/AbstractGoogleTutorialTest.java
new file mode 100644
index 0000000000..4e39f7ae6c
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/google/AbstractGoogleTutorialTest.java
@@ -0,0 +1,58 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
+package com.predic8.membrane.tutorials.ai.llmgateway.google;
+
+import com.predic8.membrane.tutorials.ai.llmgateway.AbstractAiTutorialTest;
+
+/**
+ * Base class for Google Gemini LLM-Gateway tutorial tests.
+ *
+ * Overrides the upstream URL and the API-key header so the mock captures
+ * the {@code x-goog-api-key} header that Google uses. The mock response is
+ * formatted as a Gemini {@code generateContent} reply and reports 100 total
+ * tokens (50 prompt + 50 candidates) per call.
+ */
+public abstract class AbstractGoogleTutorialTest extends AbstractAiTutorialTest {
+
+ /** URL prefix used in both Google tutorial YAML files. */
+ @Override
+ protected String getUpstreamApiUrl() {
+ return "https://generativelanguage.googleapis.com";
+ }
+
+ @Override
+ protected String getTutorialDir() {
+ return "ai/llm-gateway/google";
+ }
+
+ /** Google authenticates via the {@code x-goog-api-key} header. */
+ @Override
+ protected String getApiKeyHeader() {
+ return "x-goog-api-key";
+ }
+
+ /**
+ * Minimal Gemini {@code generateContent} reply with 50 prompt + 50 candidates = 100 total
+ * tokens. The higher per-request cost keeps the token-budget exhaustion test to three
+ * successful requests before alice's 500-token allowance runs out.
+ */
+ @Override
+ protected String mockResponse() {
+ return """
+ {"candidates":[{"content":{"parts":[{"text":"I am a mock."}],"role":"model"},\
+ "finishReason":"STOP"}],\
+ "usageMetadata":{"promptTokenCount":50,"candidatesTokenCount":50,"totalTokenCount":100}}""";
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/google/BasicGoogleLLMGatewayTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/google/BasicGoogleLLMGatewayTutorialTest.java
new file mode 100644
index 0000000000..16f52d470b
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/google/BasicGoogleLLMGatewayTutorialTest.java
@@ -0,0 +1,109 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
+package com.predic8.membrane.tutorials.ai.llmgateway.google;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static io.restassured.RestAssured.given;
+import static io.restassured.path.json.JsonPath.from;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+
+/**
+ * Integration test for
+ * {@code distribution/tutorials/ai/llm-gateway/google/10-Basic-LLM-Gateway.yaml}.
+ *
+ *
The tutorial configures a Google Gemini LLM gateway with:
+ *
+ * - {@code maxInputTokens: 100} — requests whose estimated input exceeds 100 tokens are rejected
+ * - {@code maxOutputTokens: 200} — {@code generationConfig.maxOutputTokens} in the forwarded
+ * request is capped to 200
+ *
+ *
+ * The upstream Google Gemini API is replaced by a local mock server so no real API key is needed.
+ */
+public class BasicGoogleLLMGatewayTutorialTest extends AbstractGoogleTutorialTest {
+
+ private static final String GEMINI_ENDPOINT =
+ LOCALHOST_2000 + "/v1beta/models/gemini-2.5-flash:generateContent";
+
+ @Override
+ protected String getTutorialYaml() {
+ return "10-Basic-LLM-Gateway.yaml";
+ }
+
+ /**
+ * A request within the token limits is forwarded to the upstream and its response is returned.
+ */
+ @Test
+ void simpleRequestIsForwarded() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", "test-key")
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(GEMINI_ENDPOINT)
+ .then()
+ .statusCode(200)
+ .body("candidates[0].content.parts[0].text", equalTo("I am a mock."));
+ // @formatter:on
+ }
+
+ /**
+ * A request whose message content exceeds {@code maxInputTokens} (100) is rejected by the
+ * gateway before reaching the upstream. The response uses the Google error format.
+ */
+ @Test
+ void inputTokenLimitExceededIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", "test-key")
+ .body(readFileFromBaseDir("max-input.json"))
+ .when()
+ .post(GEMINI_ENDPOINT)
+ .then()
+ .statusCode(400)
+ .body("error.status", equalTo("INVALID_ARGUMENT"))
+ .body("error.message", containsString("exceeds the maximum allowed"))
+ .body("error.message", containsString("100"));
+ // @formatter:on
+ }
+
+ /**
+ * When the request asks for more output tokens than {@code maxOutputTokens} (200) allows,
+ * the gateway rewrites {@code generationConfig.maxOutputTokens} to 200 before forwarding.
+ * The mock captures the forwarded body so we can verify the value was actually capped.
+ */
+ @Test
+ void outputTokensAreCappedBeforeForwarding() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", "test-key")
+ .body(readFileFromBaseDir("max-output.json"))
+ .when()
+ .post(GEMINI_ENDPOINT)
+ .then()
+ .statusCode(200);
+ // @formatter:on
+
+ assertThat(from(lastRequestBody).getInt("generationConfig.maxOutputTokens"), equalTo(200));
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/google/SharingApiKeysGoogleTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/google/SharingApiKeysGoogleTutorialTest.java
new file mode 100644
index 0000000000..79b1a71e3e
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/google/SharingApiKeysGoogleTutorialTest.java
@@ -0,0 +1,219 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
+package com.predic8.membrane.tutorials.ai.llmgateway.google;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+
+/**
+ * Integration tests for
+ * {@code distribution/tutorials/ai/llm-gateway/google/20-Sharing-API-Keys.yaml}.
+ *
+ *
The tutorial demonstrates sharing a single upstream API key between multiple users,
+ * each identified by their own gateway key and subject to individual token budgets:
+ *
+ * - alice — key {@code abc123}, budget 500 tokens
+ * - bob — key {@code qwertz}, budget 10 000 tokens
+ *
+ * Additional gateway limits: {@code maxInputTokens=100}, {@code maxOutputTokens=200},
+ * allowed models: {@code gemini-2.5-pro}, {@code gemini-2.5-flash}, {@code gemini-2.5-flash-lite},
+ * {@code gemini-2.0-flash}, {@code gemini-2.0-flash-lite}.
+ *
+ * For Google Gemini the model is part of the URL path
+ * ({@code /v1beta/models/:generateContent}), not the request body.
+ */
+public class SharingApiKeysGoogleTutorialTest extends AbstractGoogleTutorialTest {
+
+ private static final String ALICE = "abc123";
+ private static final String BOB = "qwertz";
+
+ private static final String GEMINI_FLASH_ENDPOINT =
+ LOCALHOST_2000 + "/v1beta/models/gemini-2.5-flash:generateContent";
+
+ @Override
+ protected String getTutorialYaml() {
+ return "20-Sharing-API-Keys.yaml";
+ }
+
+ @Test
+ void aliceCanSendRequest() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", ALICE)
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(GEMINI_FLASH_ENDPOINT)
+ .then()
+ .statusCode(200)
+ .body("candidates[0].content.parts[0].text", equalTo("I am a mock."));
+ // @formatter:on
+ }
+
+ @Test
+ void bobCanSendRequest() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", BOB)
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(GEMINI_FLASH_ENDPOINT)
+ .then()
+ .statusCode(200)
+ .body("candidates[0].content.parts[0].text", equalTo("I am a mock."));
+ // @formatter:on
+ }
+
+ @Test
+ void unknownApiKeyIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", "invalid-key")
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(GEMINI_FLASH_ENDPOINT)
+ .then()
+ .statusCode(401)
+ .body("error.status", equalTo("UNAUTHENTICATED"))
+ .body("error.message", containsString("Invalid API key"));
+ // @formatter:on
+ }
+
+ /**
+ * The gateway is configured with its own upstream {@code apiKey}. When a user request
+ * arrives carrying the user-facing key (e.g. alice's {@code abc123}), the gateway must
+ * replace it with the configured upstream key before forwarding to the LLM provider.
+ * For Google Gemini, the key is carried in the {@code x-goog-api-key} header.
+ */
+ @Test
+ void userApiKeyIsReplacedWithGatewayApiKey() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", ALICE)
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(GEMINI_FLASH_ENDPOINT)
+ .then()
+ .log().ifValidationFails()
+ .statusCode(200);
+ // @formatter:on
+
+ assertThat(lastRequestApiKey, not(equalTo(ALICE)));
+ assertThat(lastRequestApiKey, equalTo(TEST_API_KEY));
+ }
+
+ /**
+ * For Google Gemini the model is extracted from the URL path. Sending a request to
+ * {@code /v1beta/models/gpt-5:generateContent} uses model {@code gpt-5}, which is not
+ * in the allowed list, so the gateway rejects it with 400.
+ */
+ @Test
+ void wrongModelIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", ALICE)
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1beta/models/gpt-5:generateContent")
+ .then()
+ .statusCode(400)
+ .body("error.status", equalTo("INVALID_ARGUMENT"))
+ .body("error.message", containsString("gpt-5"))
+ .body("error.message", containsString("not allowed"));
+ // @formatter:on
+ }
+
+ @Test
+ void inputTokenLimitExceededIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", ALICE)
+ .body(readFileFromBaseDir("max-input.json"))
+ .when()
+ .post(GEMINI_FLASH_ENDPOINT)
+ .then()
+ .statusCode(400)
+ .body("error.status", equalTo("INVALID_ARGUMENT"))
+ .body("error.message", containsString("exceeds the maximum allowed"))
+ .body("error.message", containsString("100"));
+ // @formatter:on
+ }
+
+ /**
+ * Alice has a budget of 500 tokens. Each request with {@code max-output.json} projects
+ * 9 (input estimate) + 200 (capped maxOutputTokens) = 209 tokens. The mock returns
+ * 100 tokens of actual usage per call, so the running total grows by 100 after each response.
+ *
+ * Budget accounting per request:
+ *
+ * 1st: 500 - 0 - 209 = 291 → forwarded; used becomes 100
+ * 2nd: 500 - 100 - 209 = 191 → forwarded; used becomes 200
+ * 3rd: 500 - 200 - 209 = 91 → forwarded; used becomes 300
+ * 4th: 500 - 300 - 209 = -9 → rejected with 429
+ *
+ *
+ * Bob's separate budget of 10 000 tokens is unaffected, so he can still send requests
+ * after alice is blocked.
+ */
+ @Test
+ void alicesTokenBudgetIsExhaustedWhileBobIsUnaffected() throws IOException {
+ for (int i = 0; i < 3; i++) {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", ALICE)
+ .body(readFileFromBaseDir("max-output.json"))
+ .when()
+ .post(GEMINI_FLASH_ENDPOINT)
+ .then()
+ .statusCode(200);
+ // @formatter:on
+ }
+
+ // Alice's budget is now exhausted
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", ALICE)
+ .body(readFileFromBaseDir("max-output.json"))
+ .when()
+ .post(GEMINI_FLASH_ENDPOINT)
+ .then()
+ .statusCode(429)
+ .body("error.status", equalTo("RESOURCE_EXHAUSTED"));
+
+ // Bob's budget is independent — he can still send requests
+ given()
+ .contentType("application/json")
+ .header("x-goog-api-key", BOB)
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(GEMINI_FLASH_ENDPOINT)
+ .then()
+ .statusCode(200)
+ .body("candidates[0].content.parts[0].text", equalTo("I am a mock."));
+ // @formatter:on
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/AbstractOpenAiTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/AbstractOpenAiTutorialTest.java
new file mode 100644
index 0000000000..54136f4c2f
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/AbstractOpenAiTutorialTest.java
@@ -0,0 +1,61 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
+package com.predic8.membrane.tutorials.ai.llmgateway.openai;
+
+import com.predic8.membrane.tutorials.ai.llmgateway.AbstractAiTutorialTest;
+
+/**
+ * Base class for OpenAI LLM-Gateway tutorial tests.
+ *
+ * Overrides the upstream URL and the API-key header so the mock captures
+ * the {@code Authorization} header that OpenAI uses instead of {@code x-api-key}.
+ * The mock response is formatted as an OpenAI Responses-API reply and reports
+ * 100 total tokens (50 input + 50 output) per call.
+ */
+public abstract class AbstractOpenAiTutorialTest extends AbstractAiTutorialTest {
+
+ @Override
+ protected String getTutorialDir() {
+ return "ai/llm-gateway/openai";
+ }
+
+ @Override
+ protected String getUpstreamApiUrl() {
+ return "https://api.openai.com";
+ }
+
+ /**
+ * OpenAI authenticates via {@code Authorization: Bearer }.
+ * The full header value (including the "Bearer " prefix) is captured.
+ */
+ @Override
+ protected String getApiKeyHeader() {
+ return "authorization";
+ }
+
+ /**
+ * Minimal OpenAI Responses-API reply with 50 input + 50 output = 100 total tokens.
+ * The higher per-request cost (vs. the default Claude mock) keeps the token-budget
+ * exhaustion test to three successful requests before alice's 500-token allowance runs out.
+ */
+ @Override
+ protected String mockResponse() {
+ return """
+ {"id":"resp_mock","object":"response","model":"gpt-5-nano",\
+ "output":[{"type":"message","role":"assistant",\
+ "content":[{"type":"output_text","text":"I am a mock."}]}],\
+ "usage":{"input_tokens":50,"output_tokens":50,"total_tokens":100}}""";
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/BasicOpenAiLLMGatewayTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/BasicOpenAiLLMGatewayTutorialTest.java
new file mode 100644
index 0000000000..6dd96ee098
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/BasicOpenAiLLMGatewayTutorialTest.java
@@ -0,0 +1,105 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
+package com.predic8.membrane.tutorials.ai.llmgateway.openai;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static io.restassured.RestAssured.given;
+import static io.restassured.path.json.JsonPath.from;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+
+/**
+ * Integration test for
+ * {@code distribution/tutorials/ai/llm-gateway/openai/10-Basic-LLM-Gateway.yaml}.
+ *
+ * The tutorial configures an OpenAI LLM gateway with:
+ *
+ * - {@code maxInputTokens: 100} — requests whose estimated input exceeds 100 tokens are rejected
+ * - {@code maxOutputTokens: 200} — {@code max_output_tokens} in the forwarded request is capped to 200
+ *
+ *
+ * The upstream OpenAI API is replaced by a local mock server so no real API key is needed.
+ */
+public class BasicOpenAiLLMGatewayTutorialTest extends AbstractOpenAiTutorialTest {
+
+ @Override
+ protected String getTutorialYaml() {
+ return "10-Basic-LLM-Gateway.yaml";
+ }
+
+ /**
+ * A request within the token limits is forwarded to the upstream and its response is returned.
+ */
+ @Test
+ void simpleRequestIsForwarded() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer test-key")
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(200)
+ .body("object", equalTo("response"));
+ // @formatter:on
+ }
+
+ /**
+ * A request whose message content exceeds {@code maxInputTokens} (100) is rejected by the
+ * gateway before reaching the upstream. The response uses the OpenAI error format.
+ */
+ @Test
+ void inputTokenLimitExceededIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer test-key")
+ .body(readFileFromBaseDir("max-input.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(400)
+ .body("error.type", equalTo("invalid_request_error"))
+ .body("error.code", equalTo("context_length_exceeded"))
+ .body("error.message", containsString("100"));
+ // @formatter:on
+ }
+
+ /**
+ * When the request asks for more output tokens than {@code maxOutputTokens} (200) allows,
+ * the gateway rewrites {@code max_output_tokens} to 200 before forwarding to the upstream.
+ * The mock captures the forwarded body so we can verify the value was actually capped.
+ */
+ @Test
+ void outputTokensAreCappedBeforeForwarding() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer test-key")
+ .body(readFileFromBaseDir("max-output.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(200);
+ // @formatter:on
+
+ assertThat(from(lastRequestBody).getInt("max_output_tokens"), equalTo(200));
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/SharingApiKeysOpenAiTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/SharingApiKeysOpenAiTutorialTest.java
new file mode 100644
index 0000000000..88a6d380ad
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/SharingApiKeysOpenAiTutorialTest.java
@@ -0,0 +1,208 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
+package com.predic8.membrane.tutorials.ai.llmgateway.openai;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+
+/**
+ * Integration tests for
+ * {@code distribution/tutorials/ai/llm-gateway/openai/20-Sharing-API-Keys.yaml}.
+ *
+ *
The tutorial demonstrates sharing a single upstream API key between multiple users,
+ * each identified by their own gateway key and subject to individual token budgets:
+ *
+ * - alice — key {@code abc123}, budget 500 tokens
+ * - bob — key {@code qwertz}, budget 10 000 tokens
+ *
+ * Additional gateway limits: {@code maxInputTokens=100}, {@code maxOutputTokens=200},
+ * allowed models: {@code gpt-5.4}, {@code gpt-5-nano}, {@code gpt-5-mini}.
+ */
+public class SharingApiKeysOpenAiTutorialTest extends AbstractOpenAiTutorialTest {
+
+ private static final String ALICE = "abc123";
+ private static final String BOB = "qwertz";
+
+ @Override
+ protected String getTutorialYaml() {
+ return "20-Sharing-API-Keys.yaml";
+ }
+
+ @Test
+ void aliceCanSendRequest() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer " + ALICE)
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(200)
+ .body("object", equalTo("response"));
+ // @formatter:on
+ }
+
+ @Test
+ void bobCanSendRequest() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer " + BOB)
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(200)
+ .body("object", equalTo("response"));
+ // @formatter:on
+ }
+
+ @Test
+ void unknownApiKeyIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer invalid-key")
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(401)
+ .body("error.code", equalTo("invalid_authentication"));
+ // @formatter:on
+ }
+
+ /**
+ * The gateway is configured with its own upstream {@code apiKey}. When a user request
+ * arrives carrying the user-facing key (e.g. alice's {@code abc123}), the gateway must
+ * replace it with the configured upstream key before forwarding to the LLM provider.
+ * For OpenAI, the key is carried in the {@code Authorization: Bearer } header.
+ */
+ @Test
+ void userApiKeyIsReplacedWithGatewayApiKey() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer " + ALICE)
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(200);
+ // @formatter:on
+
+ assertThat(lastRequestApiKey, not(equalTo("Bearer " + ALICE)));
+ assertThat(lastRequestApiKey, equalTo("Bearer " + TEST_API_KEY));
+ }
+
+ @Test
+ void wrongModelIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer " + ALICE)
+ .body(readFileFromBaseDir("wrong-model.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(400)
+ .body("error.type", equalTo("invalid_request_error"))
+ .body("error.code", equalTo("model_not_allowed"))
+ .body("error.message", containsString("gpt-4"))
+ .body("error.message", containsString("not allowed"));
+ // @formatter:on
+ }
+
+ @Test
+ void inputTokenLimitExceededIsRejected() throws IOException {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer " + ALICE)
+ .body(readFileFromBaseDir("max-input.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(400)
+ .body("error.type", equalTo("invalid_request_error"))
+ .body("error.code", equalTo("context_length_exceeded"))
+ .body("error.message", containsString("maximum context length"))
+ .body("error.message", containsString("100"));
+ // @formatter:on
+ }
+
+ /**
+ * Alice has a budget of 500 tokens. Each request with {@code max-output.json} projects
+ * 9 (input estimate) + 200 (capped max_output_tokens) = 209 tokens. The mock returns
+ * 100 tokens of actual usage per call, so the running total grows by 100 after each response.
+ *
+ * Budget accounting per request:
+ *
+ * 1st: 500 - 0 - 209 = 291 → forwarded; used becomes 100
+ * 2nd: 500 - 100 - 209 = 191 → forwarded; used becomes 200
+ * 3rd: 500 - 200 - 209 = 91 → forwarded; used becomes 300
+ * 4th: 500 - 300 - 209 = -9 → rejected with 429
+ *
+ *
+ * Bob's separate budget of 10 000 tokens is unaffected, so he can still send requests
+ * after alice is blocked.
+ */
+ @Test
+ void alicesTokenBudgetIsExhaustedWhileBobIsUnaffected() throws IOException {
+ for (int i = 0; i < 3; i++) {
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer " + ALICE)
+ .body(readFileFromBaseDir("max-output.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(200);
+ // @formatter:on
+ }
+
+ // Alice's budget is now exhausted
+ // @formatter:off
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer " + ALICE)
+ .body(readFileFromBaseDir("max-output.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(429)
+ .body("error.type", equalTo("rate_limit_error"))
+ .body("error.code", equalTo("token_limit_exceeded"));
+
+ // Bob's budget is independent — he can still send requests
+ given()
+ .contentType("application/json")
+ .header("Authorization", "Bearer " + BOB)
+ .body(readFileFromBaseDir("simple.json"))
+ .when()
+ .post(LOCALHOST_2000 + "/v1/responses")
+ .then()
+ .statusCode(200)
+ .body("object", equalTo("response"));
+ // @formatter:on
+ }
+}
diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/StreamingOpenAiLLMGatewayTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/StreamingOpenAiLLMGatewayTutorialTest.java
new file mode 100644
index 0000000000..679cfca6a7
--- /dev/null
+++ b/distribution/src/test/java/com/predic8/membrane/tutorials/ai/llmgateway/openai/StreamingOpenAiLLMGatewayTutorialTest.java
@@ -0,0 +1,135 @@
+/* Copyright 2026 predic8 GmbH, www.predic8.com
+
+ 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
+
+ http://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. */
+
+package com.predic8.membrane.tutorials.ai.llmgateway.openai;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+
+import static io.restassured.path.json.JsonPath.from;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration tests for the streaming (SSE) path of
+ * {@code distribution/tutorials/ai/llm-gateway/openai/10-Basic-LLM-Gateway.yaml}.
+ *
+ * The mock upstream returns {@code Content-Type: text/event-stream} with three
+ * SSE events so the gateway's SSE processing path is exercised end-to-end without
+ * a real OpenAI connection:
+ *
+ *
+ * - {@code response.created} — initial acknowledgement
+ * - {@code response.output_text.delta} — incremental text chunk
+ * - {@code response.completed} — terminal event carrying usage statistics
+ *
+ *
+ * Because RestAssured does not handle server-sent events well, these tests use the
+ * Java {@link java.net.http.HttpClient} directly — the same approach used in
+ * {@code ServerSentEventsTutorialTest}.
+ */
+public class StreamingOpenAiLLMGatewayTutorialTest extends AbstractOpenAiTutorialTest {
+
+ private static final String RESPONSES_ENDPOINT = LOCALHOST_2000 + "/v1/responses";
+
+ @Override
+ protected String getTutorialYaml() {
+ return "10-Basic-LLM-Gateway.yaml";
+ }
+
+ /** Tell the mock server to respond as a finite SSE stream. */
+ @Override
+ protected String mockContentType() {
+ return "text/event-stream";
+ }
+
+ /**
+ * A minimal but complete SSE body: one delta event followed by the terminal
+ * {@code response.completed} event that carries the usage node the gateway
+ * reads for token accounting.
+ */
+ @Override
+ protected String mockResponse() {
+ return """
+ event: response.created
+ data: {"type":"response.created","response":{"id":"resp_mock","object":"response","status":"in_progress","model":"gpt-5-nano"}}
+
+ event: response.output_text.delta
+ data: {"type":"response.output_text.delta","item_id":"msg_mock","output_index":0,"content_index":0,"delta":"I am a mock."}
+
+ event: response.completed
+ data: {"type":"response.completed","response":{"id":"resp_mock","object":"response","status":"completed","model":"gpt-5-nano","output":[{"type":"message","id":"msg_mock","status":"completed","role":"assistant","content":[{"type":"output_text","text":"I am a mock."}]}],"usage":{"input_tokens":50,"output_tokens":50,"total_tokens":100}}}
+
+ """;
+ }
+
+ /**
+ * The gateway must forward a streaming request and pass the {@code text/event-stream}
+ * response through to the client intact. The response body must contain the SSE events
+ * emitted by the upstream, including the delta text.
+ */
+ @Test
+ void streamingResponseIsForwarded() throws IOException, InterruptedException {
+ var response = sendStreamingRequest("stream.json");
+
+ assertEquals(200, response.statusCode());
+ assertTrue(response.headers().firstValue("content-type").orElse("").contains("text/event-stream"),
+ "Expected Content-Type text/event-stream");
+ assertTrue(response.body().contains("response.output_text.delta"),
+ "SSE body must contain the delta event name");
+ assertTrue(response.body().contains("I am a mock."),
+ "SSE body must contain the delta text");
+ assertTrue(response.body().contains("response.completed"),
+ "SSE body must contain the terminal event");
+ }
+
+ /**
+ * When the request carries {@code "max_output_tokens": 500} and the gateway is
+ * configured with {@code maxOutputTokens: 200}, the gateway must rewrite the field
+ * to 200 before forwarding — even for streaming requests.
+ *
+ *
The mock captures the forwarded request body so we can assert the capped value.
+ */
+ @Test
+ void streamingOutputTokensAreCappedBeforeForwarding() throws IOException, InterruptedException {
+ var response = sendStreamingRequest("max-output-stream.json");
+
+ assertEquals(200, response.statusCode());
+ assertThat(from(lastRequestBody).getInt("max_output_tokens"), equalTo(200));
+ }
+
+ // -------------------------------------------------------------------------
+
+ private HttpResponse sendStreamingRequest(String fixture) throws IOException, InterruptedException {
+ var request = HttpRequest.newBuilder()
+ .uri(URI.create(RESPONSES_ENDPOINT))
+ .timeout(Duration.ofSeconds(10))
+ .header("Content-Type", "application/json")
+ .header("Authorization", "Bearer test-key")
+ .POST(HttpRequest.BodyPublishers.ofString(readFileFromBaseDir(fixture)))
+ .build();
+
+ try (var client = HttpClient.newHttpClient()) {
+ return client.send(request, HttpResponse.BodyHandlers.ofString());
+ }
+ }
+}
diff --git a/distribution/tutorials/ai/llm-gateway/claude/10-Basic-LLM-Gateway.yaml b/distribution/tutorials/ai/llm-gateway/claude/10-Basic-LLM-Gateway.yaml
new file mode 100644
index 0000000000..ddaaaedcf1
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/claude/10-Basic-LLM-Gateway.yaml
@@ -0,0 +1,28 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.1.json
+#
+# Tutorial: Basic LLM Gateway (Antropic Claude)
+#
+# Replace <> with your Claude API key.
+#
+# 1. Hello World
+# curl -v -H "Content-Type: application/json" -H "x-api-key: <>" -H "anthropic-version: 2023-06-01" -d @simple.json http://localhost:2000/v1/messages
+# Check the response and the Membrane logs.
+#
+# 2. Exceed the input token limit
+# curl -v -H "Content-Type: application/json" -H "x-api-key: <>" -H "anthropic-version: 2023-06-01" -d @max-input.json http://localhost:2000/v1/messages
+# Returns an error because the request exceeds maxInputTokens.
+#
+# 3. Exceed the output token limit
+# curl -v -H "Content-Type: application/json" -H "x-api-key: <>" -H "anthropic-version: 2023-06-01" -d @max-output.json http://localhost:2000/v1/messages
+# Check the Membrane log for limiting max tokens to 200
+
+api:
+ port: 2000
+ flow:
+ - llmGateway:
+ claude: {}
+ policies:
+ maxInputTokens: 100
+ maxOutputTokens: 200
+ target:
+ url: https://api.anthropic.com
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/claude/20-Sharing-API-Keys.yaml b/distribution/tutorials/ai/llm-gateway/claude/20-Sharing-API-Keys.yaml
new file mode 100644
index 0000000000..3a6a54f2f4
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/claude/20-Sharing-API-Keys.yaml
@@ -0,0 +1,57 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.1.json
+#
+# Tutorial: Sharing LLM API Keys (Claude)
+#
+# Replace <> with your Claude API key.
+#
+# Requests:
+#
+# 1. Hello AI
+# curl -v -H "Content-Type: application/json" -H "x-api-key: abc123" -H "anthropic-version: 2023-06-01" -d @simple.json http://localhost:2000/v1/messages
+# Check: Successful response
+#
+# 2. Token Limit Exceeded
+# Repeat the previous request until you receive: 429 Token Limit Exceeded
+# User alice is blocked after the limit is exceeded. Bob should still be able to send requests.
+#
+# 3. Wrong Model
+# curl -v -H "Content-Type: application/json" -H "x-api-key: abc123" -H "anthropic-version: 2023-06-01" -d @wrong-model.json http://localhost:2000/v1/messages
+# Check: Error response
+#
+# 4. Max. Input Tokens Exceeded
+# curl -v -H "Content-Type: application/json" -H "x-api-key: abc123" -H "anthropic-version: 2023-06-01" -d @max-input.json http://localhost:2000/v1/messages
+# Check: Error response
+#
+# 5. Requested Max. Output Tokens Exceeded
+# curl -v -H "Content-Type: application/json" -H "x-api-key: abc123" -H "anthropic-version: 2023-06-01" -d @max-output.json http://localhost:2000/v1/messages
+# Check Membrane log: totalTokens should not exceed 200 even though it was requested in max-output.json
+
+api:
+ port: 2000
+ flow:
+ - llmGateway:
+ claude: {}
+ apiKey: <>
+ policies:
+ # Limits per request
+ maxInputTokens: 100
+ maxOutputTokens: 200
+ models:
+ - claude-sonnet-4-0
+ - claude-opus-4-0
+ - claude-haiku-3-5
+ simpleStore:
+ # User-facing API keys for the LLM Gateway
+ users:
+ - name: alice
+ apiKey: abc123
+ tokens: 250 # Token limit for alice
+ - name: bob
+ apiKey: qwertz
+ tokens: 10000
+ # Time in seconds after which the token limit is reset
+ limitResetPeriod: 60
+ - request:
+ - log: {}
+ target:
+ url: https://api.anthropic.com
diff --git a/distribution/tutorials/ai/llm-gateway/claude/max-input.json b/distribution/tutorials/ai/llm-gateway/claude/max-input.json
new file mode 100644
index 0000000000..a51d79d50e
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/claude/max-input.json
@@ -0,0 +1,10 @@
+{
+ "model": "claude-sonnet-4-0",
+ "max_tokens": 100,
+ "messages": [
+ {
+ "role": "user",
+ "content": "Who are you, where do you get your information from, how do you answer questions, why were you created, what kinds of problems can you solve, where do you go when you search for information, how do you decide what is important, what do you know about programming, science, history, languages, and technology, how do you explain difficult concepts to people, why do people use AI assistants, what happens when you do not know an answer, and why should someone trust the answers you provide?"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/claude/max-output.json b/distribution/tutorials/ai/llm-gateway/claude/max-output.json
new file mode 100644
index 0000000000..b3746f34c6
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/claude/max-output.json
@@ -0,0 +1,10 @@
+{
+ "model": "claude-sonnet-4-0",
+ "max_tokens": 500,
+ "messages": [
+ {
+ "role": "user",
+ "content": "Explain in detail who you are?"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/claude/membrane.cmd b/distribution/tutorials/ai/llm-gateway/claude/membrane.cmd
new file mode 100644
index 0000000000..8d2d64e9cf
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/claude/membrane.cmd
@@ -0,0 +1,24 @@
+@echo off
+setlocal EnableExtensions
+
+set "SCRIPT_DIR=%~dp0"
+if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
+
+set "dir=%SCRIPT_DIR%"
+
+:search_up
+if exist "%dir%\LICENSE.txt" if exist "%dir%\scripts\run-membrane.cmd" goto found
+for %%A in ("%dir%\..") do set "next=%%~fA"
+if /I "%next%"=="%dir%" goto notfound
+set "dir=%next%"
+goto search_up
+
+:found
+set "MEMBRANE_HOME=%dir%"
+set "MEMBRANE_CALLER_DIR=%SCRIPT_DIR%"
+call "%MEMBRANE_HOME%\scripts\run-membrane.cmd" %*
+exit /b %ERRORLEVEL%
+
+:notfound
+>&2 echo Could not locate Membrane root. Ensure directory structure is correct.
+exit /b 1
diff --git a/distribution/tutorials/ai/llm-gateway/claude/membrane.sh b/distribution/tutorials/ai/llm-gateway/claude/membrane.sh
new file mode 100755
index 0000000000..195dae51ec
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/claude/membrane.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+# Default: ./proxies.xml (next to this script); fallback -> $MEMBRANE_HOME/conf/proxies.xml
+# JAVA_OPTS: relative -D paths are auto-resolved against $MEMBRANE_HOME (absolute/URI unchanged).
+# Examples:
+# export JAVA_OPTS='-Dlog4j.configurationFile=examples/logging/access/log4j2_access.xml'
+# export JAVA_OPTS='-Dlog4j.configurationFile=/abs/path/log4j2.xml'
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
+
+dir="$SCRIPT_DIR"
+while [ "$dir" != "/" ]; do
+ if [ -f "$dir/LICENSE.txt" ] && [ -f "$dir/scripts/run-membrane.sh" ]; then
+ export MEMBRANE_HOME="$dir"
+ export MEMBRANE_CALLER_DIR="$SCRIPT_DIR"
+ exec sh "$dir/scripts/run-membrane.sh" "$@"
+ fi
+ dir=$(dirname "$dir")
+done
+
+echo "Could not locate Membrane root. Ensure directory structure is correct." >&2
+exit 1
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/claude/simple.json b/distribution/tutorials/ai/llm-gateway/claude/simple.json
new file mode 100644
index 0000000000..bd6b974408
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/claude/simple.json
@@ -0,0 +1,10 @@
+{
+ "model": "claude-sonnet-4-0",
+ "max_tokens": 100,
+ "messages": [
+ {
+ "role": "user",
+ "content": "Who are you?"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/claude/wrong-model.json b/distribution/tutorials/ai/llm-gateway/claude/wrong-model.json
new file mode 100644
index 0000000000..d149716e51
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/claude/wrong-model.json
@@ -0,0 +1,10 @@
+{
+ "model": "gpt-5",
+ "max_tokens": 100,
+ "messages": [
+ {
+ "role": "user",
+ "content": "Who are you?"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/google/10-Basic-LLM-Gateway.yaml b/distribution/tutorials/ai/llm-gateway/google/10-Basic-LLM-Gateway.yaml
new file mode 100644
index 0000000000..2cbf4c236d
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/google/10-Basic-LLM-Gateway.yaml
@@ -0,0 +1,28 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.1.json
+#
+# Tutorial: Basic LLM Gateway (Google Gemini)
+#
+# Replace <> with your Google API key.
+#
+# 1. Hello World
+# curl -v -H "Content-Type: application/json" -H "x-goog-api-key: <>" -d @simple.json http://localhost:2000/v1beta/models/gemini-2.5-flash:generateContent
+# Check the response and the Membrane logs.
+#
+# 2. Exceed the input token limit
+# curl -v -H "Content-Type: application/json" -H "x-goog-api-key: <>" -d @max-input.json http://localhost:2000/v1beta/models/gemini-2.5-flash:generateContent
+# Returns an error because the request exceeds maxInputTokens.
+#
+# 3. Exceed the output token limit
+# curl -v -H "Content-Type: application/json" -H "x-goog-api-key: <>" -d @max-output.json http://localhost:2000/v1beta/models/gemini-2.5-flash:generateContent
+# Check the Membrane log for limiting max tokens to 200
+
+api:
+ port: 2000
+ flow:
+ - llmGateway:
+ google: {}
+ policies:
+ maxInputTokens: 100
+ maxOutputTokens: 200
+ target:
+ url: https://generativelanguage.googleapis.com
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/google/20-Sharing-API-Keys.yaml b/distribution/tutorials/ai/llm-gateway/google/20-Sharing-API-Keys.yaml
new file mode 100644
index 0000000000..4a9ef00ba4
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/google/20-Sharing-API-Keys.yaml
@@ -0,0 +1,57 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.1.json
+#
+# Tutorial: Sharing LLM API Keys (Google Gemini)
+#
+# Replace <> with your Gemini API key.
+#
+# Requests:
+#
+# 1. Hello AI
+# curl -v -H "Content-Type: application/json" -H "x-goog-api-key: abc123" -d @simple.json http://localhost:2000/v1beta/models/gemini-2.5-flash:generateContent
+# Check: Successful response
+#
+# 2. Token Limit Exceeded
+# Repeat the previous request until you receive: 429 Token Limit Exceeded
+# User alice is blocked after the limit is exceeded. Bob should still be able to send requests.
+#
+# 3. Wrong Model
+# curl -v -H "Content-Type: application/json" -H "x-goog-api-key: abc123" -d @simple.json http://localhost:2000/v1beta/models/gpt-5:generateContent
+# Check: Error response
+#
+# 4. Max. Input Tokens Exceeded
+# curl -v -H "Content-Type: application/json" -H "x-goog-api-key: abc123" -d @max-input.json http://localhost:2000/v1beta/models/gemini-2.5-flash:generateContent
+# Check: Error response
+#
+# 5. Requested Max. Output Tokens Exceeded
+# curl -v -H "Content-Type: application/json" -H "x-goog-api-key: abc123" -d @max-output.json http://localhost:2000/v1beta/models/gemini-2.5-flash:generateContent
+# Check Membrane log: totalTokens should not exceed 200 even though it was requested in max-output.json
+
+api:
+ port: 2000
+ flow:
+ - llmGateway:
+ google: {}
+ apiKey: <>
+ policies:
+ # Limits per request
+ maxInputTokens: 100
+ maxOutputTokens: 200
+ models:
+ - gemini-2.5-pro
+ - gemini-2.5-flash
+ - gemini-2.5-flash-lite
+ - gemini-2.0-flash
+ - gemini-2.0-flash-lite
+ simpleStore:
+ # User-facing API keys for the LLM Gateway
+ users:
+ - name: alice
+ apiKey: abc123
+ tokens: 500 # Token limit for alice
+ - name: bob
+ apiKey: qwertz
+ tokens: 10000
+ # Time in seconds after which the token limit is reset
+ limitResetPeriod: 60
+ target:
+ url: https://generativelanguage.googleapis.com
diff --git a/distribution/tutorials/ai/llm-gateway/google/max-input.json b/distribution/tutorials/ai/llm-gateway/google/max-input.json
new file mode 100644
index 0000000000..017608297f
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/google/max-input.json
@@ -0,0 +1,11 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Who are you, where do you get your information from, how do you answer questions, why were you created, what kinds of problems can you solve, where do you go when you search for information, how do you decide what is important, what do you know about programming, science, history, languages, and technology, how do you explain difficult concepts to people, why do people use AI assistants, what happens when you do not know an answer, and why should someone trust the answers you provide?"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/google/max-output.json b/distribution/tutorials/ai/llm-gateway/google/max-output.json
new file mode 100644
index 0000000000..615c6db3a0
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/google/max-output.json
@@ -0,0 +1,14 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Explain in detail who you are?"
+ }
+ ]
+ }
+ ],
+ "generationConfig": {
+ "maxOutputTokens": 500
+ }
+}
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/google/membrane.cmd b/distribution/tutorials/ai/llm-gateway/google/membrane.cmd
new file mode 100644
index 0000000000..8d2d64e9cf
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/google/membrane.cmd
@@ -0,0 +1,24 @@
+@echo off
+setlocal EnableExtensions
+
+set "SCRIPT_DIR=%~dp0"
+if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
+
+set "dir=%SCRIPT_DIR%"
+
+:search_up
+if exist "%dir%\LICENSE.txt" if exist "%dir%\scripts\run-membrane.cmd" goto found
+for %%A in ("%dir%\..") do set "next=%%~fA"
+if /I "%next%"=="%dir%" goto notfound
+set "dir=%next%"
+goto search_up
+
+:found
+set "MEMBRANE_HOME=%dir%"
+set "MEMBRANE_CALLER_DIR=%SCRIPT_DIR%"
+call "%MEMBRANE_HOME%\scripts\run-membrane.cmd" %*
+exit /b %ERRORLEVEL%
+
+:notfound
+>&2 echo Could not locate Membrane root. Ensure directory structure is correct.
+exit /b 1
diff --git a/distribution/tutorials/ai/llm-gateway/google/membrane.sh b/distribution/tutorials/ai/llm-gateway/google/membrane.sh
new file mode 100755
index 0000000000..195dae51ec
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/google/membrane.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+# Default: ./proxies.xml (next to this script); fallback -> $MEMBRANE_HOME/conf/proxies.xml
+# JAVA_OPTS: relative -D paths are auto-resolved against $MEMBRANE_HOME (absolute/URI unchanged).
+# Examples:
+# export JAVA_OPTS='-Dlog4j.configurationFile=examples/logging/access/log4j2_access.xml'
+# export JAVA_OPTS='-Dlog4j.configurationFile=/abs/path/log4j2.xml'
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
+
+dir="$SCRIPT_DIR"
+while [ "$dir" != "/" ]; do
+ if [ -f "$dir/LICENSE.txt" ] && [ -f "$dir/scripts/run-membrane.sh" ]; then
+ export MEMBRANE_HOME="$dir"
+ export MEMBRANE_CALLER_DIR="$SCRIPT_DIR"
+ exec sh "$dir/scripts/run-membrane.sh" "$@"
+ fi
+ dir=$(dirname "$dir")
+done
+
+echo "Could not locate Membrane root. Ensure directory structure is correct." >&2
+exit 1
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/google/simple.json b/distribution/tutorials/ai/llm-gateway/google/simple.json
new file mode 100644
index 0000000000..3bf6c67b2e
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/google/simple.json
@@ -0,0 +1,11 @@
+{
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": "Who are you?"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/openai/10-Basic-LLM-Gateway.yaml b/distribution/tutorials/ai/llm-gateway/openai/10-Basic-LLM-Gateway.yaml
new file mode 100644
index 0000000000..0074494b40
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/openai/10-Basic-LLM-Gateway.yaml
@@ -0,0 +1,27 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.1.json
+#
+# Tutorial: Basic LLM Gateway (OpenAI)
+#
+# Replace <> with your OpenAI API key.
+#
+# 1. Hello World
+# curl -H "Content-Type: application/json" -H "Authorization: Bearer <>" -d @simple.json http://localhost:2000/v1/responses
+#
+# 2. Exceed the input token limit
+# curl -H "Content-Type: application/json" -H "Authorization: Bearer <>" -d @max-input.json http://localhost:2000/v1/responses
+# Returns an error because the request exceeds maxInputTokens.
+#
+# 3. Exceed the output token limit
+# curl -H "Content-Type: application/json" -H "Authorization: Bearer <>" -d @max-output.json http://localhost:2000/v1/responses
+# Check the max_output_tokens field in the response and the Membrane log
+
+api:
+ port: 2000
+ flow:
+ - llmGateway:
+ openai: {}
+ policies:
+ maxInputTokens: 100
+ maxOutputTokens: 200
+ target:
+ url: https://api.openai.com
diff --git a/distribution/tutorials/ai/llm-gateway/openai/20-Sharing-API-Keys.yaml b/distribution/tutorials/ai/llm-gateway/openai/20-Sharing-API-Keys.yaml
new file mode 100644
index 0000000000..8aa3e72f4d
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/openai/20-Sharing-API-Keys.yaml
@@ -0,0 +1,55 @@
+# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.1.json
+#
+# Tutorial: Sharing LLM API Keys (OpenAI)
+#
+# Replace <> with your OpenAI API key.
+#
+# Requests:
+#
+# 1. Hello AI
+# curl -v -H "Content-Type: application/json" -H "Authorization: Bearer abc123" -d @simple.json http://localhost:2000/v1/responses
+# Check: Successful response
+#
+# 2. Token Limit Exceeded
+# Repeat the previous request until you receive: 429 Token Limit Exceeded
+# User alice is blocked after the limit is exceeded. Bob should still be able to send requests.
+#
+# 3. Wrong Model
+# curl -v -H "Content-Type: application/json" -H "Authorization: Bearer abc123" -d @wrong-model.json http://localhost:2000/v1/responses
+# Check: Error response
+#
+# 4. Max. Input Tokens Exceeded
+# curl -v -H "Content-Type: application/json" -H "Authorization: Bearer abc123" -d @max-input.json http://localhost:2000/v1/responses
+# Check: Error response
+#
+# 5. Requested Max. Output Tokens Exceeded
+# curl -v -H "Content-Type: application/json" -H "Authorization: Bearer abc123" -d @max-output.json http://localhost:2000/v1/responses
+# Check: Field max_output_tokens in the response
+
+api:
+ port: 2000
+ flow:
+ - llmGateway:
+ apiKey: <>
+ policies:
+ # Limits per request
+ maxInputTokens: 100
+ maxOutputTokens: 200
+ models:
+ - gpt-5.4
+ - gpt-5-nano
+ - gpt-5-mini
+ openai: {}
+ simpleStore:
+ # User-facing API keys for the LLM Gateway
+ users:
+ - name: alice
+ apiKey: abc123
+ tokens: 500 # Token limit for alice
+ - name: bob
+ apiKey: qwertz
+ tokens: 10000
+ # Time in seconds after which the token limit is reset
+ limitResetPeriod: 60
+ target:
+ url: https://api.openai.com/
diff --git a/distribution/tutorials/ai/llm-gateway/openai/max-input.json b/distribution/tutorials/ai/llm-gateway/openai/max-input.json
new file mode 100644
index 0000000000..e4b0e90985
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/openai/max-input.json
@@ -0,0 +1,4 @@
+{
+ "model": "gpt-5-nano",
+ "input": "Who are you, where do you get your information from, how do you answer questions, why were you created, what kinds of problems can you solve, where do you go when you search for information, how do you decide what is important, what do you know about programming, science, history, languages, and technology, how do you explain difficult concepts to people, why do people use AI assistants, what happens when you do not know an answer, and why should someone trust the answers you provide?"
+}
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/openai/max-output-stream.json b/distribution/tutorials/ai/llm-gateway/openai/max-output-stream.json
new file mode 100644
index 0000000000..0a747d70e4
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/openai/max-output-stream.json
@@ -0,0 +1,6 @@
+{
+ "model": "gpt-5-nano",
+ "input": "Explain in detail who you are?",
+ "max_output_tokens": 500,
+ "stream": true
+}
diff --git a/distribution/tutorials/ai/llm-gateway/openai/max-output.json b/distribution/tutorials/ai/llm-gateway/openai/max-output.json
new file mode 100644
index 0000000000..cc7e04017f
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/openai/max-output.json
@@ -0,0 +1,5 @@
+{
+ "model": "gpt-5-nano",
+ "input": "Explain in detail who you are?",
+ "max_output_tokens": 500
+}
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/openai/membrane.cmd b/distribution/tutorials/ai/llm-gateway/openai/membrane.cmd
new file mode 100644
index 0000000000..8d2d64e9cf
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/openai/membrane.cmd
@@ -0,0 +1,24 @@
+@echo off
+setlocal EnableExtensions
+
+set "SCRIPT_DIR=%~dp0"
+if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
+
+set "dir=%SCRIPT_DIR%"
+
+:search_up
+if exist "%dir%\LICENSE.txt" if exist "%dir%\scripts\run-membrane.cmd" goto found
+for %%A in ("%dir%\..") do set "next=%%~fA"
+if /I "%next%"=="%dir%" goto notfound
+set "dir=%next%"
+goto search_up
+
+:found
+set "MEMBRANE_HOME=%dir%"
+set "MEMBRANE_CALLER_DIR=%SCRIPT_DIR%"
+call "%MEMBRANE_HOME%\scripts\run-membrane.cmd" %*
+exit /b %ERRORLEVEL%
+
+:notfound
+>&2 echo Could not locate Membrane root. Ensure directory structure is correct.
+exit /b 1
diff --git a/distribution/tutorials/ai/llm-gateway/openai/membrane.sh b/distribution/tutorials/ai/llm-gateway/openai/membrane.sh
new file mode 100755
index 0000000000..195dae51ec
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/openai/membrane.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+# Default: ./proxies.xml (next to this script); fallback -> $MEMBRANE_HOME/conf/proxies.xml
+# JAVA_OPTS: relative -D paths are auto-resolved against $MEMBRANE_HOME (absolute/URI unchanged).
+# Examples:
+# export JAVA_OPTS='-Dlog4j.configurationFile=examples/logging/access/log4j2_access.xml'
+# export JAVA_OPTS='-Dlog4j.configurationFile=/abs/path/log4j2.xml'
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
+
+dir="$SCRIPT_DIR"
+while [ "$dir" != "/" ]; do
+ if [ -f "$dir/LICENSE.txt" ] && [ -f "$dir/scripts/run-membrane.sh" ]; then
+ export MEMBRANE_HOME="$dir"
+ export MEMBRANE_CALLER_DIR="$SCRIPT_DIR"
+ exec sh "$dir/scripts/run-membrane.sh" "$@"
+ fi
+ dir=$(dirname "$dir")
+done
+
+echo "Could not locate Membrane root. Ensure directory structure is correct." >&2
+exit 1
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/openai/simple.json b/distribution/tutorials/ai/llm-gateway/openai/simple.json
new file mode 100644
index 0000000000..ab3c4b7bde
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/openai/simple.json
@@ -0,0 +1,4 @@
+{
+ "model": "gpt-5-nano",
+ "input": "Who are you?"
+}
\ No newline at end of file
diff --git a/distribution/tutorials/ai/llm-gateway/openai/stream.json b/distribution/tutorials/ai/llm-gateway/openai/stream.json
new file mode 100644
index 0000000000..1c75ce00aa
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/openai/stream.json
@@ -0,0 +1,5 @@
+{
+ "model": "gpt-5-nano",
+ "input": "Who are you?",
+ "stream": true
+}
diff --git a/distribution/tutorials/ai/llm-gateway/openai/wrong-model.json b/distribution/tutorials/ai/llm-gateway/openai/wrong-model.json
new file mode 100644
index 0000000000..7a551564a2
--- /dev/null
+++ b/distribution/tutorials/ai/llm-gateway/openai/wrong-model.json
@@ -0,0 +1,4 @@
+{
+ "model": "gpt-4",
+ "input": "Who are you?"
+}
\ No newline at end of file