From 82211ac991dd559f72c44f3a3515a22a5f9d8f68 Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Thu, 27 Aug 2026 10:19:55 +0800 Subject: [PATCH 1/8] feat(ecosystem): add integration and cookbook hubs --- .github/workflows/deploy.yml | 9 + .../zh/docusaurus-theme-classic/navbar.json | 8 + config/navbar.js | 10 + .../zh/docusaurus-theme-classic/navbar.json | 8 + examples/redis-ai-gateway/.env.example | 10 + examples/redis-ai-gateway/.gitignore | 2 + examples/redis-ai-gateway/README.md | 78 ++++++ examples/redis-ai-gateway/compose.yaml | 69 +++++ examples/redis-ai-gateway/conf/apisix.yaml | 180 ++++++++++++ examples/redis-ai-gateway/conf/config.yaml | 20 ++ .../redis-ai-gateway/scripts/check-infra.sh | 53 ++++ examples/redis-ai-gateway/scripts/cleanup.sh | 11 + examples/redis-ai-gateway/scripts/lib.sh | 87 ++++++ examples/redis-ai-gateway/scripts/setup.sh | 55 ++++ .../redis-ai-gateway/scripts/test-cache.sh | 92 ++++++ .../scripts/test-failure-modes.sh | 45 +++ .../scripts/test-shared-quota.sh | 47 ++++ next/scripts/generate-md-twins.mjs | 21 +- next/scripts/generate-sitemaps.mjs | 7 +- next/scripts/generate-sitemaps.test.mjs | 15 + next/scripts/sync-content.mjs | 4 + .../src/components/EcosystemArticlePage.astro | 77 +++++ .../src/components/EcosystemCatalogPage.astro | 220 +++++++++++++++ next/src/components/Header.astro | 48 +++- next/src/components/HomePage.astro | 4 +- next/src/layouts/EcosystemDetail.astro | 195 +++++++++++++ next/src/lib/ecosystem.ts | 262 ++++++++++++++++++ next/src/lib/site.ts | 13 +- next/src/pages/cookbooks/[slug].astro | 11 + next/src/pages/cookbooks/index.astro | 4 + next/src/pages/integrations/[slug].astro | 11 + next/src/pages/integrations/index.astro | 4 + next/src/pages/zh/cookbooks/[slug].astro | 11 + next/src/pages/zh/cookbooks/index.astro | 4 + next/src/pages/zh/integrations/[slug].astro | 11 + next/src/pages/zh/integrations/index.astro | 4 + next/tests/e2e/ecosystem-pages.spec.mjs | 78 ++++++ website/cookbooks/en/redis-ai-cache.md | 120 ++++++++ .../cookbooks/en/redis-shared-token-quota.md | 98 +++++++ website/cookbooks/zh/redis-ai-cache.md | 108 ++++++++ .../cookbooks/zh/redis-shared-token-quota.md | 86 ++++++ .../zh/docusaurus-theme-classic/navbar.json | 8 + website/integrations/en/redis.md | 61 ++++ website/integrations/zh/redis.md | 53 ++++ website/static/llms.txt | 8 + 45 files changed, 2316 insertions(+), 14 deletions(-) create mode 100644 examples/redis-ai-gateway/.env.example create mode 100644 examples/redis-ai-gateway/.gitignore create mode 100644 examples/redis-ai-gateway/README.md create mode 100644 examples/redis-ai-gateway/compose.yaml create mode 100644 examples/redis-ai-gateway/conf/apisix.yaml create mode 100644 examples/redis-ai-gateway/conf/config.yaml create mode 100755 examples/redis-ai-gateway/scripts/check-infra.sh create mode 100755 examples/redis-ai-gateway/scripts/cleanup.sh create mode 100755 examples/redis-ai-gateway/scripts/lib.sh create mode 100755 examples/redis-ai-gateway/scripts/setup.sh create mode 100755 examples/redis-ai-gateway/scripts/test-cache.sh create mode 100755 examples/redis-ai-gateway/scripts/test-failure-modes.sh create mode 100755 examples/redis-ai-gateway/scripts/test-shared-quota.sh create mode 100644 next/src/components/EcosystemArticlePage.astro create mode 100644 next/src/components/EcosystemCatalogPage.astro create mode 100644 next/src/layouts/EcosystemDetail.astro create mode 100644 next/src/lib/ecosystem.ts create mode 100644 next/src/pages/cookbooks/[slug].astro create mode 100644 next/src/pages/cookbooks/index.astro create mode 100644 next/src/pages/integrations/[slug].astro create mode 100644 next/src/pages/integrations/index.astro create mode 100644 next/src/pages/zh/cookbooks/[slug].astro create mode 100644 next/src/pages/zh/cookbooks/index.astro create mode 100644 next/src/pages/zh/integrations/[slug].astro create mode 100644 next/src/pages/zh/integrations/index.astro create mode 100644 next/tests/e2e/ecosystem-pages.spec.mjs create mode 100644 website/cookbooks/en/redis-ai-cache.md create mode 100644 website/cookbooks/en/redis-shared-token-quota.md create mode 100644 website/cookbooks/zh/redis-ai-cache.md create mode 100644 website/cookbooks/zh/redis-shared-token-quota.md create mode 100644 website/integrations/en/redis.md create mode 100644 website/integrations/zh/redis.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3f950945b0ac2..b3bbb323b3956 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -370,6 +370,7 @@ jobs: for path in index.html zh/index.html _astro \ blog zh/blog learning-center zh/learning-center \ articles zh/articles events zh/events \ + integrations zh/integrations cookbooks zh/cookbooks \ comparisons zh/comparisons \ ai-gateway zh/ai-gateway \ plugins zh/plugins downloads zh/downloads; do @@ -469,6 +470,10 @@ jobs: test -f website/build/learning-center/atom.xml test -f website/build/articles/index.html test -f website/build/zh/articles/rss.xml + test -f website/build/integrations/redis/index.html + test -f website/build/zh/integrations/redis/index.html + test -f website/build/cookbooks/redis-ai-cache/index.html + test -f website/build/zh/cookbooks/redis-ai-cache/index.html test -f website/build/events/archive/index.html test -f website/build/docs/general/events/index.html # The comparisons hub (a single index.html in both locales) is Astro's. @@ -507,6 +512,8 @@ jobs: blog_post_twin=$(find website/build/blog -mindepth 2 -type f -name index.md -print -quit) test -n "$blog_post_twin" test -f website/build/docs/apisix/plugins/cors/index.md + test -f website/build/integrations/redis/index.md + test -f website/build/cookbooks/redis-ai-cache/index.md grep -q 'index.md' website/build/llms.txt test -f website/build/img/integrations/icon-prometheus.svg test -f website/build/img/architecture.svg @@ -580,6 +587,8 @@ jobs: run: | node next/scripts/generate-sitemaps.mjs --dist website/build grep -q 'https://apisix.apache.org/learning-center/mcp-protocol-ai-gateway/' website/build/sitemap.xml + grep -q 'https://apisix.apache.org/integrations/redis/' website/build/sitemap.xml + grep -q 'https://apisix.apache.org/cookbooks/redis-ai-cache/' website/build/sitemap.xml grep -q 'https://apisix.apache.org/zh/learning-center/' website/build/zh/sitemap.xml if grep -q 'https://apisix.apache.org/zh/learning-center/what-is-an-api-gateway/' website/build/zh/sitemap.xml; then echo 'Retired English-only Chinese learning-center URL remains in the sitemap.' diff --git a/blog/i18n/zh/docusaurus-theme-classic/navbar.json b/blog/i18n/zh/docusaurus-theme-classic/navbar.json index a29392f5888e2..8bf0a101c525e 100644 --- a/blog/i18n/zh/docusaurus-theme-classic/navbar.json +++ b/blog/i18n/zh/docusaurus-theme-classic/navbar.json @@ -19,6 +19,14 @@ "message": "相关资源", "description": "Navbar item with label Resources" }, + "item.label.Integrations": { + "message": "集成", + "description": "Navbar item with label Integrations" + }, + "item.label.Cookbooks": { + "message": "Cookbook", + "description": "Navbar item with label Cookbooks" + }, "item.label.PluginHub": { "message": "插件市场", "description": "Navbar item with label Plugin Hub" diff --git a/config/navbar.js b/config/navbar.js index 3b8013dd2f34b..c36bc1730b326 100644 --- a/config/navbar.js +++ b/config/navbar.js @@ -92,6 +92,16 @@ module.exports = [ label: 'Resources', position: 'right', items: [ + { + to: '/integrations', + label: 'Integrations', + target: '_parent', + }, + { + to: '/cookbooks', + label: 'Cookbooks', + target: '_parent', + }, { to: '/plugins', label: 'Plugin Hub', diff --git a/doc/i18n/zh/docusaurus-theme-classic/navbar.json b/doc/i18n/zh/docusaurus-theme-classic/navbar.json index 7118d332d389f..2951d82f70cab 100644 --- a/doc/i18n/zh/docusaurus-theme-classic/navbar.json +++ b/doc/i18n/zh/docusaurus-theme-classic/navbar.json @@ -19,6 +19,14 @@ "message": "相关资源", "description": "Navbar item with label Resources" }, + "item.label.Integrations": { + "message": "集成", + "description": "Navbar item with label Integrations" + }, + "item.label.Cookbooks": { + "message": "Cookbook", + "description": "Navbar item with label Cookbooks" + }, "item.label.PluginHub": { "message": "插件市场", "description": "Navbar item with label Plugin Hub" diff --git a/examples/redis-ai-gateway/.env.example b/examples/redis-ai-gateway/.env.example new file mode 100644 index 0000000000000..9ff9e0a128603 --- /dev/null +++ b/examples/redis-ai-gateway/.env.example @@ -0,0 +1,10 @@ +# scripts/setup.sh generates an isolated Compose instance ID and the three local secrets below. +LAB_INSTANCE_ID= +REDIS_PASSWORD= +CONSUMER_A_KEY= +CONSUMER_B_KEY= + +# Required only for functional cache and quota tests. The scripts never print it. +OPENAI_API_KEY= +OPENAI_CHAT_MODEL=gpt-4o-mini +OPENAI_EMBEDDING_MODEL=text-embedding-3-small diff --git a/examples/redis-ai-gateway/.gitignore b/examples/redis-ai-gateway/.gitignore new file mode 100644 index 0000000000000..926359893965f --- /dev/null +++ b/examples/redis-ai-gateway/.gitignore @@ -0,0 +1,2 @@ +.env +results/ diff --git a/examples/redis-ai-gateway/README.md b/examples/redis-ai-gateway/README.md new file mode 100644 index 0000000000000..004b3158226f2 --- /dev/null +++ b/examples/redis-ai-gateway/README.md @@ -0,0 +1,78 @@ +# Apache APISIX 3.18 with Redis® software AI Gateway lab + +This lab supports the Redis® Integration page and its two Cookbooks. It pins the gateway and store images, keeps the Redis® service and APISIX management surfaces off the host, and separates infrastructure checks from real-provider functional checks. + +## Pinned runtime + +- Apache APISIX 3.18.0, tag commit `0796d9c2cbedb1f8bf8194292ff526599f4fde20` +- `apache/apisix:3.18.0-debian@sha256:84e6b5e787e9f889ebff88161cb9a16599bafcffa236c6b54c7f779a0655940d` +- Redis® Open Source 8.10.1 with its bundled Search module explicitly loaded +- `redis:8-alpine@sha256:becdda6c7f4b3fb42e42fd7f120bbf5c54c4caaaf16f26da24e4563d2c1f0576` + +The image references are multi-architecture registry digests, but this Search-enabled lab supports only `linux/amd64` and `linux/arm64` because the official Redis® Open Source 8.10.1 image builds bundled modules only for those architectures. Record the platform-specific image ID in every published result. + +## Run the infrastructure preflight + +Requirements: Docker Compose, Bash, `awk`, `cmp`, `curl`, `jq`, and OpenSSL. + +```bash +./scripts/setup.sh +``` + +The setup script creates a mode-`0600` `.env` with a random isolated Compose instance ID and random Redis® and Consumer secrets, recreates two APISIX nodes and one private ephemeral Redis® service from a clean state, and checks: + +- both APISIX Status APIs report ready; +- only gateway ports `127.0.0.1:9080` and `127.0.0.1:9081` are published; +- the missing-key and valid-key authentication paths behave as expected without calling a provider; +- the Redis® service is not published to the host; +- the Redis® service reports version 8.10.1 and accepts `FT._LIST`; +- Compose resolved the expected immutable image digests. + +This preflight does **not** call an LLM and is not cache or quota E2E evidence. + +## Run real-provider checks + +Add a dedicated, least-privilege OpenAI API key to `.env`. The tests use `gpt-4o-mini` and `text-embedding-3-small` by default. They never print the key or prompt/response bodies. + +The Routes remove successful key-auth credentials before proxying, and the lab access-log format excludes raw query strings. Send the Consumer key in the `apikey` header; do not place credentials in URLs. + +```dotenv +OPENAI_API_KEY=replace-me +``` + +Then run: + +```bash +./scripts/test-shared-quota.sh +./scripts/test-cache.sh +./scripts/test-failure-modes.sh +``` + +The quota test sends one real request through node A, waits until post-response token usage is committed to the Redis® database, requires the counter to equal provider `usage.total_tokens`, then requires node B to return the configured `429` from the same counter. + +The cache test verifies an exact cross-node hit, byte-identical response-body replay, Consumer isolation, one semantic paraphrase hit, semantic-to-exact backfill, and an unrelated miss. It never lowers the similarity threshold automatically. + +The failure test distinguishes cache fail-open behavior from rate-limit behavior with `allow_degradation` disabled or explicitly enabled. + +## What is still outside this lab + +Passing these scripts is not enough to mark the public Cookbooks E2E verified. Publication additionally requires: + +- provider-side chat and embedding call counters, correlated to each request; +- complete and interrupted SSE cases; +- two clean runs and an independent second-operator reproduction; +- sanitized APISIX logs proving no credential or body leakage; +- separate real failover profiles before claiming Redis® Cluster or Sentinel support; +- a documented test date, machine architecture, provider region, and model identifiers. + +APISIX token accounting happens after the provider response and can overshoot under a large or concurrent response. It is not prepaid budget reservation. Cache storage supports one Redis® endpoint in APISIX 3.18.0; this lab does not claim cache HA. + +## Cleanup + +```bash +./scripts/cleanup.sh +``` + +Cleanup removes only the randomly named lab instance's containers and network. The lab does not publish or persist Redis® data, and its scripts never run `FLUSHALL` against an external service. + +Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. This community lab is not endorsed, supported, or certified by Redis®. diff --git a/examples/redis-ai-gateway/compose.yaml b/examples/redis-ai-gateway/compose.yaml new file mode 100644 index 0000000000000..9707028a9e2b1 --- /dev/null +++ b/examples/redis-ai-gateway/compose.yaml @@ -0,0 +1,69 @@ +name: apisix-redis-ai-gateway + +x-apisix-common: &apisix-common + image: "apache/apisix:3.18.0-debian@\ + sha256:84e6b5e787e9f889ebff88161cb9a16599bafcffa236c6b54c7f779a0655940d" + environment: + REDIS_PASSWORD: "${REDIS_PASSWORD:?run scripts/setup.sh first}" + CONSUMER_A_KEY: "${CONSUMER_A_KEY:?run scripts/setup.sh first}" + CONSUMER_B_KEY: "${CONSUMER_B_KEY:?run scripts/setup.sh first}" + OPENAI_API_KEY: "${OPENAI_API_KEY:-not-configured}" + OPENAI_AUTHORIZATION: "Bearer ${OPENAI_API_KEY:-not-configured}" + OPENAI_CHAT_MODEL: "${OPENAI_CHAT_MODEL:-gpt-4o-mini}" + OPENAI_EMBEDDING_MODEL: "${OPENAI_EMBEDDING_MODEL:-text-embedding-3-small}" + volumes: + - ./conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro + - ./conf/apisix.yaml:/usr/local/apisix/conf/apisix.yaml:ro + depends_on: + redis: + condition: service_healthy + networks: + - lab + healthcheck: + test: + - CMD-SHELL + - >- + bash -ec 'exec 3<>/dev/tcp/127.0.0.1/7085; + printf "GET /status/ready HTTP/1.0\r\nHost: localhost\r\n\r\n" >&3; + IFS= read -r line <&3; + [[ "$$line" == *" 200 "* ]]' + interval: 2s + timeout: 2s + retries: 30 + start_period: 10s + +services: + redis: + image: redis:8-alpine@sha256:becdda6c7f4b3fb42e42fd7f120bbf5c54c4caaaf16f26da24e4563d2c1f0576 + environment: + REDIS_PASSWORD: "${REDIS_PASSWORD:?run scripts/setup.sh first}" + command: + - sh + - -ec + - >- + exec redis-server --requirepass "$${REDIS_PASSWORD}" + --appendonly no --save '' + --loadmodule /usr/local/lib/redis/modules/redisearch.so + networks: + - lab + healthcheck: + test: + - CMD-SHELL + - REDISCLI_AUTH="$${REDIS_PASSWORD}" redis-cli ping | grep -q PONG + interval: 2s + timeout: 2s + retries: 30 + # Redis is private and ephemeral: no host port and no persistent volume. + + apisix-a: + <<: *apisix-common + ports: + - "127.0.0.1:9080:9080" + + apisix-b: + <<: *apisix-common + ports: + - "127.0.0.1:9081:9080" + +networks: + lab: {} diff --git a/examples/redis-ai-gateway/conf/apisix.yaml b/examples/redis-ai-gateway/conf/apisix.yaml new file mode 100644 index 0000000000000..226cd9f53fbda --- /dev/null +++ b/examples/redis-ai-gateway/conf/apisix.yaml @@ -0,0 +1,180 @@ +consumers: + - username: redis-lab-a + plugins: + key-auth: + key: "$env://CONSUMER_A_KEY" + - username: redis-lab-b + plugins: + key-auth: + key: "$env://CONSUMER_B_KEY" + +routes: + - id: shared-token-quota + uri: /labs/shared-quota + methods: [POST] + plugins: + key-auth: + hide_credentials: true + proxy-rewrite: + headers: + remove: [Authorization, Cookie, X-API-Key, apikey] + ai-proxy: + provider: openai + auth: + header: + Authorization: "$env://OPENAI_AUTHORIZATION" + options: + model: "$env://OPENAI_CHAT_MODEL" + temperature: 0 + override: + llm_options: + max_tokens: 32 + timeout: 60000 + max_req_body_size: 65536 + max_response_bytes: 262144 + max_stream_duration_ms: 60000 + ssl_verify: true + ai-rate-limiting: + # Deliberately tiny so one real response crosses the quota. + limit: 1 + time_window: 600 + limit_strategy: total_tokens + rejected_code: 429 + rejected_msg: shared token quota exhausted + show_limit_quota_header: true + policy: redis + redis_host: redis + redis_port: 6379 + redis_database: 1 + redis_password: "$env://REDIS_PASSWORD" + allow_degradation: false + + - id: shared-token-quota-degraded + uri: /labs/shared-quota-degraded + methods: [POST] + plugins: + key-auth: + hide_credentials: true + proxy-rewrite: + headers: + remove: [Authorization, Cookie, X-API-Key, apikey] + ai-proxy: + provider: openai + auth: + header: + Authorization: "$env://OPENAI_AUTHORIZATION" + options: + model: "$env://OPENAI_CHAT_MODEL" + temperature: 0 + override: + llm_options: + max_tokens: 32 + timeout: 60000 + max_req_body_size: 65536 + max_response_bytes: 262144 + max_stream_duration_ms: 60000 + ssl_verify: true + ai-rate-limiting: + limit: 1000 + time_window: 600 + limit_strategy: total_tokens + rejected_code: 429 + policy: redis + redis_host: redis + redis_port: 6379 + redis_database: 3 + redis_password: "$env://REDIS_PASSWORD" + allow_degradation: true + + - id: exact-cache + uri: /labs/cache/exact + methods: [POST] + plugins: + key-auth: + hide_credentials: true + proxy-rewrite: + headers: + remove: [Authorization, Cookie, X-API-Key, apikey] + ai-proxy: + provider: openai + auth: + header: + Authorization: "$env://OPENAI_AUTHORIZATION" + options: + model: "$env://OPENAI_CHAT_MODEL" + temperature: 0 + override: + llm_options: + max_tokens: 32 + timeout: 60000 + max_req_body_size: 65536 + max_response_bytes: 262144 + max_stream_duration_ms: 60000 + ssl_verify: true + ai-cache: + layers: [exact] + exact: + ttl: 600 + cache_key: + include_consumer: true + cache_headers: true + fail_mode: error + max_cache_body_size: 262144 + redis_host: redis + redis_port: 6379 + redis_database: 2 + redis_password: "$env://REDIS_PASSWORD" + + - id: semantic-cache + uri: /labs/cache/semantic + methods: [POST] + plugins: + key-auth: + hide_credentials: true + proxy-rewrite: + headers: + remove: [Authorization, Cookie, X-API-Key, apikey] + ai-proxy: + provider: openai + auth: + header: + Authorization: "$env://OPENAI_AUTHORIZATION" + options: + model: "$env://OPENAI_CHAT_MODEL" + temperature: 0 + override: + llm_options: + max_tokens: 32 + timeout: 60000 + max_req_body_size: 65536 + max_response_bytes: 262144 + max_stream_duration_ms: 60000 + ssl_verify: true + ai-cache: + layers: [exact, semantic] + exact: + ttl: 600 + cache_key: + include_consumer: true + cache_headers: true + fail_mode: error + max_cache_body_size: 262144 + redis_host: redis + redis_port: 6379 + redis_database: 0 + redis_password: "$env://REDIS_PASSWORD" + semantic: + # Lab threshold only. Calibrate production thresholds against false matches. + similarity_threshold: 0.80 + top_k: 1 + ttl: 600 + embedding: + openai: + model: "$env://OPENAI_EMBEDDING_MODEL" + api_key: "$env://OPENAI_API_KEY" + ssl_verify: true + timeout: 10000 + vector_search: + redis: + index: apisix-cookbook-semantic +# END diff --git a/examples/redis-ai-gateway/conf/config.yaml b/examples/redis-ai-gateway/conf/config.yaml new file mode 100644 index 0000000000000..90dde9b13b4de --- /dev/null +++ b/examples/redis-ai-gateway/conf/config.yaml @@ -0,0 +1,20 @@ +apisix: + node_listen: + - 9080 + status: + ip: 127.0.0.1 + port: 7085 + +nginx_config: + http: + # Do not log raw query strings, which may contain caller credentials. + access_log_format: >- + $remote_addr - $remote_user [$time_local] $http_host + "$request_method $uri $server_protocol" $status $body_bytes_sent + $request_time $upstream_addr $upstream_status $upstream_response_time + "$apisix_request_id" + +deployment: + role: data_plane + role_data_plane: + config_provider: yaml diff --git a/examples/redis-ai-gateway/scripts/check-infra.sh b/examples/redis-ai-gateway/scripts/check-infra.sh new file mode 100755 index 0000000000000..3675a2ac3cdfd --- /dev/null +++ b/examples/redis-ai-gateway/scripts/check-infra.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +require_cmd curl +require_cmd docker +require_env_file + +[[ "$(redis_cli --raw PING)" == 'PONG' ]] || fail 'Redis PING failed' +redis_cli --raw INFO server | tr -d '\r' | grep -q '^redis_version:8\.10\.1$' \ + || fail 'Redis version is not the pinned 8.10.1 release' +redis_cli --raw FT._LIST >/dev/null + +for port in 9080 9081; do + status=$(curl --silent --show-error --connect-timeout 3 --max-time 5 \ + --output /dev/null --write-out '%{http_code}' "http://127.0.0.1:${port}/labs/not-found") + [[ "$status" == '404' ]] || fail "APISIX on port $port is not ready (HTTP $status)" +done + +unauthenticated_status=$(curl --silent --show-error --connect-timeout 3 --max-time 5 \ + --output /dev/null --write-out '%{http_code}' \ + --request POST --header 'Content-Type: application/json' --data '{}' \ + 'http://127.0.0.1:9080/labs/shared-quota') +[[ "$unauthenticated_status" == '401' ]] \ + || fail "missing Consumer key expected HTTP 401, got $unauthenticated_status" +consumer_a=$(env_value CONSUMER_A_KEY) +authenticated_status=$(curl_with_consumer_key "$consumer_a" \ + --silent --show-error --connect-timeout 3 --max-time 5 \ + --output /dev/null --write-out '%{http_code}' \ + --request POST --header 'Content-Type: application/json' \ + --data '{' \ + 'http://127.0.0.1:9080/labs/shared-quota') +[[ "$authenticated_status" == '400' ]] \ + || fail "valid Consumer key with malformed JSON expected HTTP 400, got $authenticated_status" + +redis_container=$(compose ps -q redis) +[[ "$redis_container" =~ ^[a-f0-9]{12,64}$ ]] || fail 'could not resolve the isolated Redis container ID' +redis_published_ports=$(docker inspect --format \ + '{{range $port, $bindings := .NetworkSettings.Ports}}{{if $bindings}}{{$port}}{{end}}{{end}}' \ + "$redis_container") +[[ -z "$redis_published_ports" ]] || fail "Redis ports must not be published to the host: $redis_published_ports" +[[ "$(compose port apisix-a 9080)" == '127.0.0.1:9080' ]] || fail 'apisix-a must bind only to 127.0.0.1:9080' +[[ "$(compose port apisix-b 9080)" == '127.0.0.1:9081' ]] || fail 'apisix-b must bind only to 127.0.0.1:9081' + +images=$(compose config --images) +grep -Fqx 'apache/apisix:3.18.0-debian@sha256:84e6b5e787e9f889ebff88161cb9a16599bafcffa236c6b54c7f779a0655940d' <<<"$images" +grep -Fqx 'redis:8-alpine@sha256:becdda6c7f4b3fb42e42fd7f120bbf5c54c4caaaf16f26da24e4563d2c1f0576' <<<"$images" + +printf 'PASS: pinned APISIX nodes, private Redis 8.10.1, authentication, and Redis Search are ready.\n' +printf 'NOTE: this is infrastructure evidence only; it does not verify cache or quota behavior.\n' diff --git a/examples/redis-ai-gateway/scripts/cleanup.sh b/examples/redis-ai-gateway/scripts/cleanup.sh new file mode 100755 index 0000000000000..d2bc11f339eda --- /dev/null +++ b/examples/redis-ai-gateway/scripts/cleanup.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +require_cmd docker +require_env_file +compose down --remove-orphans --volumes +printf 'Removed only this isolated apisix-redis-ai-gateway lab instance.\n' diff --git a/examples/redis-ai-gateway/scripts/lib.sh b/examples/redis-ai-gateway/scripts/lib.sh new file mode 100755 index 0000000000000..486af7e74cfda --- /dev/null +++ b/examples/redis-ai-gateway/scripts/lib.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +LAB_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +COMPOSE_FILE="$LAB_ROOT/compose.yaml" +ENV_FILE="$LAB_ROOT/.env" + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} + +require_env_file() { + local env_mode lab_instance_id + [[ -f "$ENV_FILE" ]] || fail "missing $ENV_FILE; run scripts/setup.sh" + [[ ! -L "$ENV_FILE" ]] || fail "refusing symlinked environment file: $ENV_FILE" + case "$(uname -s)" in + Darwin) env_mode=$(stat -f '%Lp' "$ENV_FILE") ;; + *) env_mode=$(stat -c '%a' "$ENV_FILE") ;; + esac + [[ "$env_mode" == '600' ]] || fail "$ENV_FILE must have mode 600; run scripts/setup.sh" + lab_instance_id=$(sed -n 's/^LAB_INSTANCE_ID=//p' "$ENV_FILE" | tail -n 1) + [[ "$lab_instance_id" =~ ^[a-f0-9]{16}$ ]] \ + || fail "LAB_INSTANCE_ID in $ENV_FILE must be 16 lowercase hexadecimal characters; run scripts/setup.sh" +} + +compose() { + local lab_instance_id + require_env_file + lab_instance_id=$(env_value LAB_INSTANCE_ID) + docker compose --project-name "apisix-redis-ai-gateway-$lab_instance_id" \ + --project-directory "$LAB_ROOT" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" +} + +env_value() { + local key=$1 + sed -n "s/^${key}=//p" "$ENV_FILE" | tail -n 1 +} + +require_provider() { + local key + key=$(env_value OPENAI_API_KEY) + [[ -n "$key" ]] || fail "OPENAI_API_KEY is empty; infrastructure can be checked, but real cache/quota tests require provider credentials" +} + +redis_cli() { + compose exec -T redis sh -c 'REDISCLI_AUTH="$REDIS_PASSWORD" exec redis-cli -e "$@"' sh "$@" +} + +header_value() { + local name=$1 file=$2 + awk -v wanted="$name" ' + tolower($1) == tolower(wanted ":") { sub(/^[^:]+:[[:space:]]*/, ""); sub(/\r$/, ""); value = $0 } + END { print value } + ' "$file" +} + +curl_with_consumer_key() { + local consumer_key=$1 + shift + [[ "$consumer_key" =~ ^[a-f0-9]{48}$ ]] || fail 'Consumer key must be 48 lowercase hexadecimal characters' + curl --config <(printf 'header = "apikey: %s"\n' "$consumer_key") "$@" +} + +post_chat() { + local port=$1 path=$2 consumer_key=$3 payload=$4 headers=$5 body=$6 + curl_with_consumer_key "$consumer_key" \ + --silent --show-error --connect-timeout 5 --max-time 90 \ + --dump-header "$headers" --output "$body" --write-out '%{http_code}' \ + "http://127.0.0.1:${port}${path}" \ + --request POST \ + --header 'Content-Type: application/json' \ + --data "$payload" +} + +safe_tmpdir() { + local dir + dir=$(mktemp -d "${TMPDIR:-/tmp}/apisix-redis-lab.XXXXXX") + case "$dir" in + /tmp/apisix-redis-lab.*|/var/folders/*/apisix-redis-lab.*|/private/var/*/apisix-redis-lab.*) printf '%s\n' "$dir" ;; + *) fail "refusing unexpected temporary directory: $dir" ;; + esac +} diff --git a/examples/redis-ai-gateway/scripts/setup.sh b/examples/redis-ai-gateway/scripts/setup.sh new file mode 100755 index 0000000000000..254cf247a8562 --- /dev/null +++ b/examples/redis-ai-gateway/scripts/setup.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +require_cmd docker +require_cmd openssl + +[[ -z "${DOCKER_DEFAULT_PLATFORM:-}" ]] \ + || fail 'unset DOCKER_DEFAULT_PLATFORM; this lab must use the Docker Server native architecture' +docker_platform=$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}') +case "$docker_platform" in + linux/amd64|linux/arm64) ;; + *) fail "Redis Search modules in the pinned image support only linux/amd64 and linux/arm64 (Docker Server platform: $docker_platform)" ;; +esac + +[[ ! -L "$ENV_FILE" ]] || fail "refusing symlinked environment file: $ENV_FILE" + +if [[ ! -f "$ENV_FILE" ]]; then + umask 077 + lab_instance_id=$(openssl rand -hex 8) + redis_password=$(openssl rand -hex 24) + consumer_a_key=$(openssl rand -hex 24) + consumer_b_key=$(openssl rand -hex 24) + printf '%s\n' \ + "LAB_INSTANCE_ID=$lab_instance_id" \ + "REDIS_PASSWORD=$redis_password" \ + "CONSUMER_A_KEY=$consumer_a_key" \ + "CONSUMER_B_KEY=$consumer_b_key" \ + 'OPENAI_API_KEY=' \ + 'OPENAI_CHAT_MODEL=gpt-4o-mini' \ + 'OPENAI_EMBEDDING_MODEL=text-embedding-3-small' >"$ENV_FILE" + chmod 600 "$ENV_FILE" + current_lab_id=$lab_instance_id + printf 'Created %s with local Redis and Consumer secrets.\n' "$ENV_FILE" + printf 'Add OPENAI_API_KEY there before running functional tests.\n' +else + current_lab_id=$(sed -n 's/^LAB_INSTANCE_ID=//p' "$ENV_FILE" | tail -n 1) +fi + +if [[ -f "$ENV_FILE" && ! "${current_lab_id:-}" =~ ^[a-f0-9]{16}$ ]]; then + umask 077 + lab_instance_id=$(openssl rand -hex 8) + printf 'LAB_INSTANCE_ID=%s\n' "$lab_instance_id" >>"$ENV_FILE" + chmod 600 "$ENV_FILE" + printf 'Added an isolated Compose instance ID to %s.\n' "$ENV_FILE" +fi + +chmod 600 "$ENV_FILE" + +compose config --quiet +compose up --detach --wait --force-recreate +"$SCRIPT_DIR/check-infra.sh" diff --git a/examples/redis-ai-gateway/scripts/test-cache.sh b/examples/redis-ai-gateway/scripts/test-cache.sh new file mode 100755 index 0000000000000..506bf3f60fb2b --- /dev/null +++ b/examples/redis-ai-gateway/scripts/test-cache.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +require_cmd awk +require_cmd cmp +require_cmd curl +require_cmd docker +require_env_file +require_provider +compose up --detach --wait + +tmp_dir=$(safe_tmpdir) +cleanup() { rm -rf -- "$tmp_dir"; } +trap cleanup EXIT + +consumer_a=$(env_value CONSUMER_A_KEY) +consumer_b=$(env_value CONSUMER_B_KEY) +exact_payload='{"messages":[{"role":"user","content":"In one sentence, what is Apache APISIX?"}]}' + +redis_cli -n 2 FLUSHDB >/dev/null +status=$(post_chat 9080 /labs/cache/exact "$consumer_a" "$exact_payload" "$tmp_dir/exact-miss.headers" "$tmp_dir/exact-miss.json") +[[ "$status" == '200' ]] || fail "exact miss expected HTTP 200, got $status" +[[ "$(header_value X-AI-Cache-Status "$tmp_dir/exact-miss.headers")" == 'MISS' ]] || fail 'first exact request was not MISS' + +for _ in {1..40}; do + [[ "$(redis_cli -n 2 --raw DBSIZE)" == '1' ]] && break + sleep 0.25 +done +[[ "$(redis_cli -n 2 --raw DBSIZE)" == '1' ]] || fail 'exact cache entry was not written in time' + +status=$(post_chat 9081 /labs/cache/exact "$consumer_a" "$exact_payload" "$tmp_dir/exact-hit.headers" "$tmp_dir/exact-hit.json") +[[ "$status" == '200' ]] || fail "exact hit expected HTTP 200, got $status" +[[ "$(header_value X-AI-Cache-Status "$tmp_dir/exact-hit.headers")" == 'HIT' ]] || fail 'second exact request was not HIT' +[[ -z "$(header_value X-AI-Cache-Similarity "$tmp_dir/exact-hit.headers")" ]] || fail 'exact HIT unexpectedly exposed a similarity score' +[[ "$(header_value X-AI-Cache-Age "$tmp_dir/exact-hit.headers")" =~ ^[0-9]+$ ]] || fail 'exact HIT age is not a non-negative integer' +cmp --silent "$tmp_dir/exact-miss.json" "$tmp_dir/exact-hit.json" || fail 'exact HIT body differs from the stored response' + +status=$(post_chat 9081 /labs/cache/exact "$consumer_b" "$exact_payload" "$tmp_dir/tenant-b.headers" "$tmp_dir/tenant-b.json") +[[ "$status" == '200' ]] || fail "Consumer B request expected HTTP 200, got $status" +[[ "$(header_value X-AI-Cache-Status "$tmp_dir/tenant-b.headers")" == 'MISS' ]] || fail 'Consumer B reused Consumer A cache entry' + +while IFS= read -r index; do + [[ "$index" == apisix-cookbook-semantic* ]] || continue + redis_cli -n 0 FT.DROPINDEX "$index" DD >/dev/null +done < <(redis_cli -n 0 --raw FT._LIST) +redis_cli -n 0 FLUSHDB >/dev/null + +anchor='{"messages":[{"role":"user","content":"What is Apache APISIX?"}]}' +paraphrase='{"messages":[{"role":"user","content":"Can you explain what Apache APISIX is?"}]}' +unrelated='{"messages":[{"role":"user","content":"Name the capital of Japan."}]}' + +status=$(post_chat 9080 /labs/cache/semantic "$consumer_a" "$anchor" "$tmp_dir/semantic-anchor.headers" "$tmp_dir/semantic-anchor.json") +[[ "$status" == '200' ]] || fail "semantic anchor expected HTTP 200, got $status" +[[ "$(header_value X-AI-Cache-Status "$tmp_dir/semantic-anchor.headers")" == 'MISS' ]] || fail 'semantic anchor was not MISS' + +semantic_index='' +semantic_docs=0 +for _ in {1..80}; do + semantic_index=$(redis_cli -n 0 --raw FT._LIST | sed -n '/^apisix-cookbook-semantic/{p;q;}') + if [[ -n "$semantic_index" ]]; then + semantic_docs=$(redis_cli -n 0 --raw FT.INFO "$semantic_index" \ + | awk '$0 == "num_docs" { getline; print; exit }') + [[ "$semantic_docs" =~ ^[0-9]+$ ]] && (( semantic_docs > 0 )) && break + fi + sleep 0.25 +done +[[ -n "$semantic_index" && "$semantic_docs" =~ ^[0-9]+$ ]] && (( semantic_docs > 0 )) \ + || fail 'semantic Redis Search index did not receive the anchor document in time' + +status=$(post_chat 9081 /labs/cache/semantic "$consumer_a" "$paraphrase" "$tmp_dir/semantic-hit.headers" "$tmp_dir/semantic-hit.json") +[[ "$status" == '200' ]] || fail "semantic paraphrase expected HTTP 200, got $status" +[[ "$(header_value X-AI-Cache-Status "$tmp_dir/semantic-hit.headers")" == 'HIT' ]] || fail 'paraphrase did not hit semantic cache' +similarity=$(header_value X-AI-Cache-Similarity "$tmp_dir/semantic-hit.headers") +awk -v score="$similarity" 'BEGIN { exit !(score >= 0.80 && score <= 1) }' \ + || fail "semantic similarity is outside [0.80, 1]: $similarity" +cmp --silent "$tmp_dir/semantic-anchor.json" "$tmp_dir/semantic-hit.json" || fail 'semantic HIT body differs from the anchor response' + +status=$(post_chat 9080 /labs/cache/semantic "$consumer_a" "$paraphrase" "$tmp_dir/backfill.headers" "$tmp_dir/backfill.json") +[[ "$status" == '200' ]] || fail "semantic backfill request expected HTTP 200, got $status" +[[ "$(header_value X-AI-Cache-Status "$tmp_dir/backfill.headers")" == 'HIT' ]] || fail 'semantic result was not backfilled to exact cache' +[[ -z "$(header_value X-AI-Cache-Similarity "$tmp_dir/backfill.headers")" ]] || fail 'backfilled exact HIT still exposed semantic similarity' + +status=$(post_chat 9081 /labs/cache/semantic "$consumer_a" "$unrelated" "$tmp_dir/unrelated.headers" "$tmp_dir/unrelated.json") +[[ "$status" == '200' ]] || fail "unrelated prompt expected HTTP 200, got $status" +[[ "$(header_value X-AI-Cache-Status "$tmp_dir/unrelated.headers")" == 'MISS' ]] || fail 'unrelated prompt incorrectly hit semantic cache' + +printf 'PASS: exact cross-node HIT, Consumer isolation, semantic HIT, L2-to-L1 backfill, and unrelated MISS.\n' +printf 'BOUNDARY: provider-side call counters and interrupted SSE still require separate evidence.\n' diff --git a/examples/redis-ai-gateway/scripts/test-failure-modes.sh b/examples/redis-ai-gateway/scripts/test-failure-modes.sh new file mode 100755 index 0000000000000..09fe75dca94a7 --- /dev/null +++ b/examples/redis-ai-gateway/scripts/test-failure-modes.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +require_cmd curl +require_cmd docker +require_env_file +require_provider +compose up --detach --wait + +tmp_dir=$(safe_tmpdir) +cleanup() { + local exit_status=$? + trap - EXIT + if ! rm -rf -- "$tmp_dir"; then + printf 'ERROR: failed to remove temporary test files: %s\n' "$tmp_dir" >&2 + (( exit_status != 0 )) || exit_status=1 + fi + if ! compose up --detach --wait --wait-timeout 60 redis >/dev/null; then + printf 'ERROR: failed to restore the isolated Redis service after the failure-mode test\n' >&2 + (( exit_status != 0 )) || exit_status=1 + fi + exit "$exit_status" +} +trap cleanup EXIT + +consumer_key=$(env_value CONSUMER_A_KEY) +payload='{"messages":[{"role":"user","content":"Reply with exactly: healthy"}]}' +compose stop redis >/dev/null + +status=$(post_chat 9080 /labs/cache/exact "$consumer_key" "$payload" "$tmp_dir/cache.headers" "$tmp_dir/cache.json") +[[ "$status" == '200' ]] || fail "cache Redis outage should continue to the provider, got HTTP $status" +[[ "$(header_value X-AI-Cache-Status "$tmp_dir/cache.headers")" == 'MISS' ]] || fail 'cache Redis outage did not report MISS' + +status=$(post_chat 9080 /labs/shared-quota "$consumer_key" "$payload" "$tmp_dir/closed.headers" "$tmp_dir/closed.json") +[[ "$status" == '500' ]] || fail "quota fail-closed route expected HTTP 500, got $status" + +status=$(post_chat 9081 /labs/shared-quota-degraded "$consumer_key" "$payload" "$tmp_dir/open.headers" "$tmp_dir/open.json") +[[ "$status" == '200' ]] || fail "quota degradation route expected provider HTTP 200, got $status" + +printf 'PASS: cache failed open to MISS; shared quota failed closed or explicitly degraded as configured.\n' +printf 'WARNING: allow_degradation=true traffic is available but not quota-protected.\n' diff --git a/examples/redis-ai-gateway/scripts/test-shared-quota.sh b/examples/redis-ai-gateway/scripts/test-shared-quota.sh new file mode 100755 index 0000000000000..e5ca306abe6ff --- /dev/null +++ b/examples/redis-ai-gateway/scripts/test-shared-quota.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +require_cmd curl +require_cmd docker +require_cmd jq +require_env_file +require_provider +compose up --detach --wait + +tmp_dir=$(safe_tmpdir) +cleanup() { rm -rf -- "$tmp_dir"; } +trap cleanup EXIT + +consumer_key=$(env_value CONSUMER_A_KEY) +payload='{"messages":[{"role":"user","content":"Reply with only the number two: one plus one."}]}' +redis_cli -n 1 FLUSHDB >/dev/null + +status=$(post_chat 9080 /labs/shared-quota "$consumer_key" "$payload" "$tmp_dir/a.headers" "$tmp_dir/a.json") +[[ "$status" == '200' ]] || fail "node A expected HTTP 200, got $status" +tokens=$(jq -er '.usage.total_tokens | select(type == "number" and . > 1)' "$tmp_dir/a.json") \ + || fail 'provider response did not contain usage.total_tokens > 1' + +counter_value='' +for _ in {1..40}; do + counter_key=$(redis_cli -n 1 --raw --scan --pattern '*ai-rate-limiting*' | sed -n '1p') + if [[ -n "$counter_key" ]]; then + counter_value=$(redis_cli -n 1 --raw GET "$counter_key") + [[ "$counter_value" =~ ^[0-9]+$ ]] && (( counter_value == tokens )) && break + fi + sleep 0.25 +done +[[ "$counter_value" =~ ^[0-9]+$ ]] && (( counter_value == tokens )) \ + || fail "Redis counter $counter_value did not equal provider usage.total_tokens $tokens in time" + +status=$(post_chat 9081 /labs/shared-quota "$consumer_key" "$payload" "$tmp_dir/b.headers" "$tmp_dir/b.json") +[[ "$status" == '429' ]] || fail "node B expected HTTP 429, got $status" +grep -Fq 'shared token quota exhausted' "$tmp_dir/b.json" || fail 'node B rejection body did not match the configured message' +tr -d '\r' <"$tmp_dir/b.headers" | grep -Eiq '^X-AI-RateLimit-Remaining-[^:]*:[[:space:]]*0$' \ + || fail 'node B did not expose a zero remaining-quota header' + +printf 'PASS: node A committed %s provider tokens; node B enforced the same Redis counter with HTTP 429.\n' "$tokens" +printf 'BOUNDARY: the crossing response succeeded; this is post-response accounting, not prepaid reservation.\n' diff --git a/next/scripts/generate-md-twins.mjs b/next/scripts/generate-md-twins.mjs index 3984d9000ba7a..67a3c0b01cb72 100644 --- a/next/scripts/generate-md-twins.mjs +++ b/next/scripts/generate-md-twins.mjs @@ -35,6 +35,10 @@ const COLLECTIONS = [ // to these URLs directly; retired /zh/learning-center// pages redirect. ['learning-center', ['/learning-center']], ['articles', ['/articles', '/zh/articles']], + ['integrations-en', ['/integrations']], + ['integrations-zh', ['/zh/integrations']], + ['cookbooks-en', ['/cookbooks']], + ['cookbooks-zh', ['/zh/cookbooks']], ['docs-general', ['/docs/general', '/zh/docs/general']], // The zh APISIX docs fall back to the English source where no translation // exists, so the English collection is also offered the zh prefix. The zh @@ -171,15 +175,19 @@ const llms = [ '', '> Apache APISIX is a dynamic, real-time, high-performance API gateway and AI gateway.', '', - 'Every page below is available as Markdown — append `index.md` to any page URL.', - '', ...(curated ? [curated, ''] : []), ...(curated ? ['# Full index', ''] : []), + 'Every content-detail page in the full index below is available as Markdown — append `index.md` to its page URL.', + '', ...section('Documentation', group(en, '/docs/')), ...section('Learning center', group(en, '/learning-center/')), + ...section('Integrations', group(en, '/integrations/')), + ...section('Cookbooks', group(en, '/cookbooks/')), ...section('Blog', group(en, '/blog/')), ...section('Articles', group(en, '/articles/')), ...section('中文文档', group(zh, '/docs/')), + ...section('中文集成', group(zh, '/integrations/')), + ...section('中文 Cookbook', group(zh, '/cookbooks/')), ...section('中文博客', group(zh, '/blog/')), ...section('中文技术文章', group(zh, '/articles/')), ].join('\n'); @@ -189,9 +197,8 @@ fs.writeFileSync(path.join(dist, 'llms.txt'), `${llms}\n`); console.log(`markdown twins: ${written.size} written, ${skipped} source files had no built page`); console.log(`llms.txt: ${en.length} en + ${zh.length} zh pages indexed`); -// Every content page must have a twin, and every twin must be indexed — -// otherwise the "append index.md to any page URL" promise is a lie for some -// subset of pages. Fail the build rather than shipping a partial surface. +// Every content-detail page must have a twin, and every twin must be indexed. +// Section landing pages are intentionally excluded from this promise. const llmsText = fs.readFileSync(path.join(dist, 'llms.txt'), 'utf8'); const indexedUrls = [...llmsText.matchAll(/\]\(https:\/\/apisix\.apache\.org([^)]*?)index\.md\)/g)].map((m) => m[1]); const indexed = new Set(indexedUrls); @@ -202,8 +209,8 @@ if (indexedUrls.length !== indexed.size) { for (const u of dupes.slice(0, 10)) console.error(` ${u}`); process.exit(1); } -const CONTENT_PREFIXES = ['/blog/', '/learning-center/', '/articles/', '/docs/', - '/zh/blog/', '/zh/learning-center/', '/zh/articles/', '/zh/docs/']; +const CONTENT_PREFIXES = ['/blog/', '/learning-center/', '/articles/', '/integrations/', '/cookbooks/', '/docs/', + '/zh/blog/', '/zh/learning-center/', '/zh/articles/', '/zh/integrations/', '/zh/cookbooks/', '/zh/docs/']; // Section landing pages (/blog/, /docs/, …) are component-rendered indexes // with no markdown source, as are listing, tag, and archive pages. const SECTION_INDEX = new Set([...CONTENT_PREFIXES, diff --git a/next/scripts/generate-sitemaps.mjs b/next/scripts/generate-sitemaps.mjs index e2f46d5150e54..3e24112b08ae5 100644 --- a/next/scripts/generate-sitemaps.mjs +++ b/next/scripts/generate-sitemaps.mjs @@ -75,8 +75,9 @@ const en = all.filter((u) => !zh.includes(u)); function getPriority(url) { if (/^\/(?:zh\/)?$/.test(url)) return '1.0'; - if (/\/(?:ai-gateway|plugins|downloads|docs|learning-center)\/$/.test(url)) return '0.8'; + if (/\/(?:ai-gateway|plugins|downloads|docs|learning-center|integrations|cookbooks)\/$/.test(url)) return '0.8'; if (url.includes('/learning-center/')) return '0.8'; + if (url.includes('/integrations/') || url.includes('/cookbooks/')) return '0.7'; if (/\/blog\/\d{4}\//.test(url)) return '0.6'; if (url.includes('/docs/')) return '0.7'; return '0.5'; @@ -84,8 +85,10 @@ function getPriority(url) { function getChangefreq(url) { if (/^\/(?:zh\/)?$/.test(url)) return 'weekly'; + if (/\/(?:integrations|cookbooks)\/$/.test(url)) return 'weekly'; if (/\/blog\/\d{4}\//.test(url)) return 'monthly'; - if (url.includes('/docs/') || url.includes('/learning-center/')) return 'monthly'; + if (url.includes('/docs/') || url.includes('/learning-center/') + || url.includes('/integrations/') || url.includes('/cookbooks/')) return 'monthly'; return 'weekly'; } diff --git a/next/scripts/generate-sitemaps.test.mjs b/next/scripts/generate-sitemaps.test.mjs index 0235bdfdd7fa4..8e84b7bec687e 100644 --- a/next/scripts/generate-sitemaps.test.mjs +++ b/next/scripts/generate-sitemaps.test.mjs @@ -29,6 +29,10 @@ const pages = [ '404', 'learning-center', 'learning-center/mcp-protocol-ai-gateway', + 'integrations', + 'integrations/redis', + 'cookbooks', + 'cookbooks/redis-ai-cache', 'learning-center/tags/ai-gateway', 'learning-center/page/2', 'learning-center/archive', @@ -52,6 +56,10 @@ const pages = [ 'search', 'zh', 'zh/learning-center', + 'zh/integrations', + 'zh/integrations/redis', + 'zh/cookbooks', + 'zh/cookbooks/redis-ai-cache', 'zh/learning-center/tags/api-gateway', 'zh/articles/page/2', 'zh/events/archive', @@ -109,17 +117,24 @@ try { ]; assert.match(en, /learning-center\/mcp-protocol-ai-gateway/); + assert.match(en, /integrations\/redis/); + assert.match(en, /cookbooks\/redis-ai-cache/); assert.match(en, /articles\/Apache-APISIX-Incubator-Journey/); assert.match(en, /blog\/2026\/07\/28\/release-notes/); assert.match(en, /docs\/general\/blog\/page\/overview/); assert.match(en, /docs\/apisix\/upgrade-guide-from-2\.15\.x-to-3\.0\.0/); assert.match(en, /apisix-unity-group-q&a/); assert.match(zh, /zh\/learning-center\/<\/loc>/); + assert.match(zh, /zh\/integrations\/redis/); + assert.match(zh, /zh\/cookbooks\/redis-ai-cache/); assert.match(zh, /bi-weekly%20report/); assert.ok(en.includes('https://apisix.apache.org/weekly1.0')); assert.ok(en.includes('https://apisix.apache.org/learning-center/monthly0.8')); assert.ok(en.includes('https://apisix.apache.org/learning-center/mcp-protocol-ai-gateway/monthly0.8')); + assert.ok(en.includes('https://apisix.apache.org/integrations/weekly0.8')); + assert.ok(en.includes('https://apisix.apache.org/integrations/redis/monthly0.7')); + assert.ok(en.includes('https://apisix.apache.org/cookbooks/redis-ai-cache/monthly0.7')); assert.ok(en.includes('https://apisix.apache.org/blog/2026/07/28/release-notes/monthly0.6')); assert.ok(en.includes('https://apisix.apache.org/docs/general/blog/page/overview/monthly0.7')); excluded.forEach((url) => assert.equal(`${en}${zh}`.includes(url), false, url)); diff --git a/next/scripts/sync-content.mjs b/next/scripts/sync-content.mjs index f1790fc3721d4..b0945dfe0e512 100644 --- a/next/scripts/sync-content.mjs +++ b/next/scripts/sync-content.mjs @@ -163,6 +163,10 @@ copyTree(path.join(WEBSITE_REPO, 'blog/en/blog'), path.join(OUT, 'blog-en'), { b copyTree(path.join(WEBSITE_REPO, 'blog/zh/blog'), path.join(OUT, 'blog-zh'), { blogBase: '/zh/blog' }); copyTree(path.join(WEBSITE_REPO, 'website/learning-center'), path.join(OUT, 'learning-center')); copyTree(path.join(WEBSITE_REPO, 'website/articles'), path.join(OUT, 'articles')); +copyTree(path.join(WEBSITE_REPO, 'website/integrations/en'), path.join(OUT, 'integrations-en')); +copyTree(path.join(WEBSITE_REPO, 'website/integrations/zh'), path.join(OUT, 'integrations-zh')); +copyTree(path.join(WEBSITE_REPO, 'website/cookbooks/en'), path.join(OUT, 'cookbooks-en')); +copyTree(path.join(WEBSITE_REPO, 'website/cookbooks/zh'), path.join(OUT, 'cookbooks-zh')); copyTree(path.join(WEBSITE_REPO, 'website/docs/general'), path.join(OUT, 'docs-general'), { docBase: '/docs/general', ghProject: 'apisix-website' }); diff --git a/next/src/components/EcosystemArticlePage.astro b/next/src/components/EcosystemArticlePage.astro new file mode 100644 index 0000000000000..9dbf1af43af7e --- /dev/null +++ b/next/src/components/EcosystemArticlePage.astro @@ -0,0 +1,77 @@ +--- +import EcosystemDetail from '../layouts/EcosystemDetail.astro'; +import { + cookbookCategoryLabels, + findIntegration, + integrationCategoryLabels, + localize, + relatedCookbooks, + type CookbookEntry, + type IntegrationEntry, +} from '../lib/ecosystem'; +import { localePrefix, t, type Locale } from '../lib/site'; + +interface Props { + locale: Locale; + resource: IntegrationEntry | CookbookEntry; +} + +const { locale, resource } = Astro.props; +const p = localePrefix(locale); +const Content = resource.mod.Content; +const title = resource.kind === 'integration' ? resource.name : resource.title; +const category = resource.kind === 'integration' + ? localize(locale, integrationCategoryLabels[resource.category]) + : localize(locale, cookbookCategoryLabels[resource.category]); +const linkedCookbooks = resource.kind === 'integration' + ? relatedCookbooks(locale, resource.slug) + : []; +const linkedIntegrations = resource.kind === 'cookbook' + ? resource.integrations + .map((slug) => findIntegration(locale, slug)) + .filter((entry): entry is IntegrationEntry => Boolean(entry)) + : []; +--- + + + + {linkedCookbooks.length > 0 && ( +
+ + +
+ )} + + {linkedIntegrations.length > 0 && ( +
+

{t(locale, 'Used integrations', '使用的集成')}

+ +
+ )} +
diff --git a/next/src/components/EcosystemCatalogPage.astro b/next/src/components/EcosystemCatalogPage.astro new file mode 100644 index 0000000000000..b8704aa3e1d76 --- /dev/null +++ b/next/src/components/EcosystemCatalogPage.astro @@ -0,0 +1,220 @@ +--- +import Base from '../layouts/Base.astro'; +import { + cookbookCategoryLabels, + getCookbooks, + getIntegrations, + integrationCategoryLabels, + localize, + verificationLabels, + type CookbookEntry, + type IntegrationEntry, +} from '../lib/ecosystem'; +import { SITE, localePrefix, t, type Locale } from '../lib/site'; + +interface Props { + kind: 'integrations' | 'cookbooks'; + locale: Locale; +} + +const { kind, locale } = Astro.props; +const p = localePrefix(locale); +const isIntegrations = kind === 'integrations'; +const entries = isIntegrations ? getIntegrations(locale) : getCookbooks(locale); +const labels = isIntegrations ? integrationCategoryLabels : cookbookCategoryLabels; +const path = isIntegrations ? '/integrations/' : '/cookbooks/'; +const title = isIntegrations + ? t(locale, 'Apache APISIX Integrations', 'Apache APISIX 集成') + : t(locale, 'Apache APISIX Cookbooks', 'Apache APISIX Cookbook'); +const heading = isIntegrations + ? t(locale, 'Connect APISIX to your stack', '将 APISIX 接入你的技术栈') + : t(locale, 'Run an outcome, not just a config', '运行一个完整场景,而不只是复制配置'); +const description = isIntegrations + ? t( + locale, + 'Browse documented Apache APISIX integrations with explicit connection methods, version scope, verification status, and runnable cookbooks.', + '浏览 Apache APISIX 集成,并查看明确的接入方式、版本范围、验证状态和可运行 Cookbook。', + ) + : t( + locale, + 'Reproducible Apache APISIX recipes with pinned versions, observable checks, failure cases, security boundaries, and cleanup steps.', + '可复现的 Apache APISIX 场景配方,包含固定版本、可观察验收、失败用例、安全边界和清理步骤。', + ); + +const categoryOrder = Object.keys(labels); +const groups = categoryOrder + .map((category) => ({ + category, + label: localize(locale, labels[category as keyof typeof labels]), + entries: entries.filter((entry) => entry.category === category), + })) + .filter((group) => group.entries.length > 0); + +const localizedHref = (href: string) => (href.startsWith('http') ? href : `${p}${href}`); +const nameOf = (entry: IntegrationEntry | CookbookEntry) => ( + entry.kind === 'integration' ? entry.name : entry.title +); +const descriptionOf = (entry: IntegrationEntry | CookbookEntry) => entry.description; +const canonicalUrl = (href: string) => (href.startsWith('http') ? href : `${SITE}${p}${href}`); + +const collectionSchema = { + '@context': 'https://schema.org', + '@type': 'CollectionPage', + name: title, + description, + url: `${SITE}${p}${path}`, + mainEntity: { + '@type': 'ItemList', + numberOfItems: entries.length, + itemListElement: entries.map((entry, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: nameOf(entry), + url: canonicalUrl(entry.href), + })), + }, +}; +--- + +
+
+
+

{isIntegrations ? 'Integration Hub' : 'Runnable Cookbooks'}

+

{heading}

+

{description}

+
+
+
{entries.length}
{isIntegrations ? t(locale, 'integrations', '项集成') : t(locale, 'cookbooks', '篇 Cookbook')}
+
{groups.length}
{t(locale, 'categories', '个类别')}
+
100%
{t(locale, 'APISIX-side open source', 'APISIX 侧开源')}
+
+
+
+ + + + + diff --git a/next/src/components/Header.astro b/next/src/components/Header.astro index 2c0018111367a..bee849024a4f6 100644 --- a/next/src/components/Header.astro +++ b/next/src/components/Header.astro @@ -23,7 +23,7 @@ const switchUrl = switchPath ?? (locale === 'zh' ? path : `/zh${path}`); ) : ( @@ -47,10 +47,54 @@ const switchUrl = switchPath ?? (locale === 'zh' ? path : `/zh${path}`);
+ + diff --git a/next/src/components/HomePage.astro b/next/src/components/HomePage.astro index fb7be926089f3..3fb7ca979c571 100644 --- a/next/src/components/HomePage.astro +++ b/next/src/components/HomePage.astro @@ -48,7 +48,7 @@ const INTEGRATIONS: { name: string; img: string | null; href: string }[] = [ { name: 'Kubernetes', img: '/img/integrations/kubernetes.svg', href: '/docs/apisix/discovery/kubernetes/' }, { name: 'Consul', img: '/img/integrations/consul.svg', href: '/docs/apisix/discovery/consul/' }, { name: 'NGINX', img: '/img/integrations/nginx.svg', href: '/docs/apisix/architecture-design/apisix/' }, - { name: 'Redis', img: '/img/integrations/redis.svg', href: '/docs/apisix/plugins/limit-count/' }, + { name: 'Redis', img: '/img/integrations/redis.svg', href: '/integrations/redis/' }, { name: 'OpenID Connect', img: '/img/integrations/icon-openid-connect.svg', href: '/docs/apisix/plugins/openid-connect/' }, { name: 'Keycloak', img: '/img/integrations/icon-authz-keycloak.svg', href: '/docs/apisix/plugins/authz-keycloak/' }, { name: 'Casbin', img: '/img/integrations/casbin.png', href: '/docs/apisix/plugins/authz-casbin/' }, @@ -234,7 +234,7 @@ const SOFTWARE_APPLICATION_SCHEMA = { {it.name} ))} -

{t(locale, 'Explore all 100+ plugins →', '浏览全部 100+ 插件 →')}

+

{t(locale, 'Explore integrations →', '浏览集成 →')}

diff --git a/next/src/layouts/EcosystemDetail.astro b/next/src/layouts/EcosystemDetail.astro new file mode 100644 index 0000000000000..08922ca5c2fa2 --- /dev/null +++ b/next/src/layouts/EcosystemDetail.astro @@ -0,0 +1,195 @@ +--- +import Base from './Base.astro'; +import { + localize, + verificationLabels, + type VerificationStatus, +} from '../lib/ecosystem'; +import { SITE, localePrefix, t, type Locale } from '../lib/site'; + +interface Props { + locale: Locale; + kind: 'integration' | 'cookbook'; + title: string; + description: string; + path: string; + category: string; + verification: VerificationStatus; + owner: string; + apisixVersion: string; + externalVersion?: string; + lastVerified?: string; + verificationUrl?: string; + reviewedAt: string; + evidenceUrl: string; + protocols?: string[]; + difficulty?: string; + duration?: string; +} + +const { + locale, + kind, + title, + description, + path, + category, + verification, + owner, + apisixVersion, + externalVersion, + lastVerified, + verificationUrl, + reviewedAt, + evidenceUrl, + protocols = [], + difficulty, + duration, +} = Astro.props; +const p = localePrefix(locale); +const hubPath = kind === 'integration' ? '/integrations/' : '/cookbooks/'; +const hubLabel = kind === 'integration' + ? t(locale, 'Integrations', '集成') + : 'Cookbooks'; + +const articleSchema = { + '@context': 'https://schema.org', + '@type': 'TechArticle', + headline: title, + description, + url: `${SITE}${p}${path}`, + mainEntityOfPage: `${SITE}${p}${path}`, + inLanguage: locale === 'zh' ? 'zh-CN' : 'en', + about: { + '@type': 'SoftwareApplication', + name: kind === 'integration' ? 'Apache APISIX integration' : 'Apache APISIX cookbook', + applicationCategory: 'DeveloperApplication', + }, + author: { + '@type': 'Organization', + name: 'Apache APISIX', + url: SITE, + }, + publisher: { + '@type': 'Organization', + name: 'Apache APISIX', + url: SITE, + }, + isPartOf: { + '@type': 'CollectionPage', + name: hubLabel, + url: `${SITE}${p}${hubPath}`, + }, + dateModified: lastVerified ?? reviewedAt, +}; +const breadcrumbSchema = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Apache APISIX', item: `${SITE}${p}/` }, + { '@type': 'ListItem', position: 2, name: hubLabel, item: `${SITE}${p}${hubPath}` }, + { '@type': 'ListItem', position: 3, name: title, item: `${SITE}${p}${path}` }, + ], +}; +--- + +
+
+
+ +

{category}

+

{title}

+

{description}

+
+ + {localize(locale, verificationLabels[verification])} + + {t(locale, 'Maintained by', '维护者')} {owner} +
+
+ +
+
APISIX
{apisixVersion}
+ {externalVersion &&
{t(locale, 'Dependency', '依赖')}
{externalVersion}
} + {protocols.length > 0 &&
{t(locale, 'Protocols', '协议')}
{protocols.join(', ')}
} + {difficulty &&
{t(locale, 'Difficulty', '难度')}
{difficulty}
} + {duration &&
{t(locale, 'Time', '时间')}
{duration}
} +
+
{t(locale, 'Last verified', '最后验证')}
+
{lastVerified && verificationUrl + ? {lastVerified} + : t(locale, 'Pending', '待完成')}
+
+
{t(locale, 'Source review', '源码核对')}
{reviewedAt}
+
+
+
+ +
+ {verification === 'validation-in-progress' && ( + + )} +
+ +
+
+ + + diff --git a/next/src/lib/ecosystem.ts b/next/src/lib/ecosystem.ts new file mode 100644 index 0000000000000..7b3e78f177b1a --- /dev/null +++ b/next/src/lib/ecosystem.ts @@ -0,0 +1,262 @@ +import type { MdModule } from './content'; +import type { Locale } from './site'; + +export type LocalizedText = { en: string; zh: string }; +export type VerificationStatus = 'verified' | 'documented' | 'validation-in-progress'; +export type IntegrationCategory = 'data'; +export type CookbookCategory = 'cost' | 'reliability'; + +interface BaseResource { + slug: string; + description: string; + verification: VerificationStatus; + owner: string; + apisixVersion: string; + externalVersion: string; + reviewedAt: string; + evidenceUrl: string; + lastVerified?: string; + verificationUrl?: string; + href: string; + mod: MdModule; +} + +export interface IntegrationEntry extends BaseResource { + kind: 'integration'; + name: string; + category: IntegrationCategory; + method: string; + protocols: string[]; + icon?: string; +} + +export interface CookbookEntry extends BaseResource { + kind: 'cookbook'; + title: string; + category: CookbookCategory; + difficulty: string; + duration: string; + integrations: string[]; + plugins: string[]; +} + +type MdMap = { [key: string]: MdModule }; + +// Vite requires literal glob patterns. Files are copied here by sync-content.mjs. +const integrationEnModules = import.meta.glob('/content/integrations-en/*.md', { eager: true }) as MdMap; +const integrationZhModules = import.meta.glob('/content/integrations-zh/*.md', { eager: true }) as MdMap; +const cookbookEnModules = import.meta.glob('/content/cookbooks-en/*.md', { eager: true }) as MdMap; +const cookbookZhModules = import.meta.glob('/content/cookbooks-zh/*.md', { eager: true }) as MdMap; + +export const verificationLabels: { [key: VerificationStatus]: LocalizedText } = { + verified: { en: 'E2E verified', zh: '端到端已验证' }, + documented: { en: 'Documented', zh: '文档已核对' }, + 'validation-in-progress': { en: 'Validation in progress', zh: '验证进行中' }, +}; + +export const integrationCategoryLabels: { [key: IntegrationCategory]: LocalizedText } = { + data: { en: 'Data, cache, and rate-limit backends', zh: '数据、缓存与限流后端' }, +}; + +export const cookbookCategoryLabels: { [key: CookbookCategory]: LocalizedText } = { + cost: { en: 'Cost and quotas', zh: '成本与配额' }, + reliability: { en: 'Reliability and resilience', zh: '可靠性与韧性' }, +}; + +export function localize(locale: Locale, value: LocalizedText | string): string { + return typeof value === 'string' ? value : value[locale]; +} + +function sourceName(mod: MdModule): string { + return mod.file || 'ecosystem markdown'; +} + +function requiredString(mod: MdModule, key: string): string { + const value = mod.frontmatter[key]; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${sourceName(mod)}: frontmatter ${key} must be a non-empty string`); + } + return value.trim(); +} + +function stringArray(mod: MdModule, key: string): string[] { + const value = mod.frontmatter[key]; + if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || item.trim() === '')) { + throw new Error(`${sourceName(mod)}: frontmatter ${key} must be a non-empty string array`); + } + return value.map((item) => item.trim()); +} + +function modulesBySlug(modules: MdMap, locale: Locale): Map { + return Object.values(modules).reduce((result, mod) => { + const slug = requiredString(mod, 'slug'); + if (result.has(slug)) { + throw new Error(`Duplicate ${locale} ecosystem slug: ${slug}`); + } + result.set(slug, mod); + return result; + }, new Map()); +} + +function localizedModule( + enMod: MdModule, + locale: Locale, + zhBySlug: Map, +): MdModule { + if (locale === 'en') return enMod; + const slug = requiredString(enMod, 'slug'); + const zhMod = zhBySlug.get(slug); + if (!zhMod) throw new Error(`${sourceName(enMod)}: missing Chinese translation for ${slug}`); + if (requiredString(zhMod, 'translation_of') !== slug) { + throw new Error(`${sourceName(zhMod)}: translation_of must equal ${slug}`); + } + return zhMod; +} + +function verification(mod: MdModule): VerificationStatus { + const value = requiredString(mod, 'verification'); + if (!['verified', 'documented', 'validation-in-progress'].includes(value)) { + throw new Error(`${sourceName(mod)}: unsupported verification status ${value}`); + } + if (value === 'verified') { + requiredString(mod, 'last_verified'); + requiredString(mod, 'verification_url'); + } + return value as VerificationStatus; +} + +function integrationCategory(mod: MdModule): IntegrationCategory { + const value = requiredString(mod, 'category'); + if (value !== 'data') throw new Error(`${sourceName(mod)}: unsupported integration category ${value}`); + return value; +} + +function cookbookCategory(mod: MdModule): CookbookCategory { + const value = requiredString(mod, 'category'); + if (!['cost', 'reliability'].includes(value)) { + throw new Error(`${sourceName(mod)}: unsupported cookbook category ${value}`); + } + return value as CookbookCategory; +} + +function integrationFromModule( + enMod: MdModule, + locale: Locale, + zhBySlug: Map, +): IntegrationEntry { + const translated = localizedModule(enMod, locale, zhBySlug); + const slug = requiredString(enMod, 'slug'); + const status = verification(enMod); + return { + kind: 'integration', + slug, + name: requiredString(translated, 'title'), + description: requiredString(translated, 'description'), + category: integrationCategory(enMod), + method: requiredString(translated, 'method'), + verification: status, + owner: requiredString(enMod, 'owner'), + apisixVersion: requiredString(enMod, 'apisix_version'), + externalVersion: requiredString(enMod, 'external_version'), + protocols: stringArray(enMod, 'protocols'), + reviewedAt: requiredString(enMod, 'reviewed_at'), + evidenceUrl: requiredString(enMod, 'evidence_url'), + ...(status === 'verified' ? { + lastVerified: requiredString(enMod, 'last_verified'), + verificationUrl: requiredString(enMod, 'verification_url'), + } : {}), + href: `/integrations/${slug}/`, + ...(typeof enMod.frontmatter.icon === 'string' ? { icon: enMod.frontmatter.icon } : {}), + mod: translated, + }; +} + +function cookbookFromModule( + enMod: MdModule, + locale: Locale, + zhBySlug: Map, +): CookbookEntry { + const translated = localizedModule(enMod, locale, zhBySlug); + const slug = requiredString(enMod, 'slug'); + const status = verification(enMod); + return { + kind: 'cookbook', + slug, + title: requiredString(translated, 'title'), + description: requiredString(translated, 'description'), + category: cookbookCategory(enMod), + difficulty: requiredString(translated, 'difficulty'), + duration: requiredString(enMod, 'duration'), + verification: status, + owner: requiredString(enMod, 'owner'), + apisixVersion: requiredString(enMod, 'apisix_version'), + externalVersion: requiredString(enMod, 'external_version'), + integrations: stringArray(enMod, 'integrations'), + plugins: stringArray(enMod, 'plugins'), + reviewedAt: requiredString(enMod, 'reviewed_at'), + evidenceUrl: requiredString(enMod, 'evidence_url'), + ...(status === 'verified' ? { + lastVerified: requiredString(enMod, 'last_verified'), + verificationUrl: requiredString(enMod, 'verification_url'), + } : {}), + href: `/cookbooks/${slug}/`, + mod: translated, + }; +} + +function validateTranslations(enModules: MdMap, zhModules: MdMap, collection: string): void { + const enBySlug = modulesBySlug(enModules, 'en'); + const zhBySlug = modulesBySlug(zhModules, 'zh'); + enBySlug.forEach((_, slug) => { + const zhMod = zhBySlug.get(slug); + if (!zhMod) throw new Error(`${collection}: missing Chinese translation for ${slug}`); + if (requiredString(zhMod, 'translation_of') !== slug) { + throw new Error(`${sourceName(zhMod)}: translation_of must equal ${slug}`); + } + }); + zhBySlug.forEach((_, slug) => { + if (!enBySlug.has(slug)) throw new Error(`${collection}: Chinese translation has no English source: ${slug}`); + }); +} + +function build(locale: Locale): { integrations: IntegrationEntry[]; cookbooks: CookbookEntry[] } { + validateTranslations(integrationEnModules, integrationZhModules, 'integrations'); + validateTranslations(cookbookEnModules, cookbookZhModules, 'cookbooks'); + const integrationZhBySlug = modulesBySlug(integrationZhModules, 'zh'); + const cookbookZhBySlug = modulesBySlug(cookbookZhModules, 'zh'); + const integrations = Object.values(integrationEnModules) + .map((mod) => integrationFromModule(mod, locale, integrationZhBySlug)) + .sort((a, b) => a.name.localeCompare(b.name)); + const cookbooks = Object.values(cookbookEnModules) + .map((mod) => cookbookFromModule(mod, locale, cookbookZhBySlug)) + .sort((a, b) => a.title.localeCompare(b.title)); + const integrationSlugs = new Set(integrations.map((entry) => entry.slug)); + cookbooks.forEach((cookbook) => { + cookbook.integrations.forEach((slug) => { + if (!integrationSlugs.has(slug)) { + throw new Error(`${sourceName(cookbook.mod)}: unknown integration ${slug}`); + } + }); + }); + return { integrations, cookbooks }; +} + +export function getIntegrations(locale: Locale): IntegrationEntry[] { + return build(locale).integrations; +} + +export function getCookbooks(locale: Locale): CookbookEntry[] { + return build(locale).cookbooks; +} + +export function findIntegration(locale: Locale, slug: string): IntegrationEntry | undefined { + return getIntegrations(locale).find((entry) => entry.slug === slug); +} + +export function findCookbook(locale: Locale, slug: string): CookbookEntry | undefined { + return getCookbooks(locale).find((entry) => entry.slug === slug); +} + +export function relatedCookbooks(locale: Locale, integrationSlug: string): CookbookEntry[] { + return getCookbooks(locale).filter((entry) => entry.integrations.includes(integrationSlug)); +} diff --git a/next/src/lib/site.ts b/next/src/lib/site.ts index 2429d6d870dc6..2fd0362ac0404 100644 --- a/next/src/lib/site.ts +++ b/next/src/lib/site.ts @@ -44,7 +44,16 @@ export const NAV: NavItem[] = [ { label: 'Comparisons', labelZh: '对比', href: '/comparisons/' }, { label: 'AI Gateway', href: '/ai-gateway/' }, { label: 'Blog', labelZh: '博客', href: '/blog/' }, - { label: 'Plugin Hub', labelZh: '插件中心', href: '/plugins/' }, + { + label: 'Ecosystem', + labelZh: '生态', + href: '/integrations/', + items: [ + { label: 'Integrations', labelZh: '集成', href: '/integrations/' }, + { label: 'Cookbooks', labelZh: 'Cookbook', href: '/cookbooks/' }, + { label: 'Plugin Hub', labelZh: '插件中心', href: '/plugins/' }, + ], + }, { label: 'Downloads', labelZh: '下载', href: '/downloads/' }, { label: 'Team', labelZh: '团队', href: '/team/' }, ]; @@ -77,6 +86,8 @@ export const FOOTER = { { label: 'Blog', href: '/blog/' }, { label: 'Learning Center', href: '/learning-center/' }, { label: 'Comparisons', href: '/comparisons/' }, + { label: 'Integrations', href: '/integrations/' }, + { label: 'Cookbooks', href: '/cookbooks/' }, { label: 'Events', href: '/docs/general/events/' }, { label: 'Case Studies', href: '/blog/tags/case-studies/' }, ], diff --git a/next/src/pages/cookbooks/[slug].astro b/next/src/pages/cookbooks/[slug].astro new file mode 100644 index 0000000000000..4a39701cd6d8e --- /dev/null +++ b/next/src/pages/cookbooks/[slug].astro @@ -0,0 +1,11 @@ +--- +import EcosystemArticlePage from '../../components/EcosystemArticlePage.astro'; +import { getCookbooks } from '../../lib/ecosystem'; + +export function getStaticPaths() { + return getCookbooks('en').map((resource) => ({ params: { slug: resource.slug }, props: { resource } })); +} + +const { resource } = Astro.props; +--- + diff --git a/next/src/pages/cookbooks/index.astro b/next/src/pages/cookbooks/index.astro new file mode 100644 index 0000000000000..078bbf9970c10 --- /dev/null +++ b/next/src/pages/cookbooks/index.astro @@ -0,0 +1,4 @@ +--- +import EcosystemCatalogPage from '../../components/EcosystemCatalogPage.astro'; +--- + diff --git a/next/src/pages/integrations/[slug].astro b/next/src/pages/integrations/[slug].astro new file mode 100644 index 0000000000000..8854298c8b1a8 --- /dev/null +++ b/next/src/pages/integrations/[slug].astro @@ -0,0 +1,11 @@ +--- +import EcosystemArticlePage from '../../components/EcosystemArticlePage.astro'; +import { getIntegrations } from '../../lib/ecosystem'; + +export function getStaticPaths() { + return getIntegrations('en').map((resource) => ({ params: { slug: resource.slug }, props: { resource } })); +} + +const { resource } = Astro.props; +--- + diff --git a/next/src/pages/integrations/index.astro b/next/src/pages/integrations/index.astro new file mode 100644 index 0000000000000..45024eeee4c23 --- /dev/null +++ b/next/src/pages/integrations/index.astro @@ -0,0 +1,4 @@ +--- +import EcosystemCatalogPage from '../../components/EcosystemCatalogPage.astro'; +--- + diff --git a/next/src/pages/zh/cookbooks/[slug].astro b/next/src/pages/zh/cookbooks/[slug].astro new file mode 100644 index 0000000000000..6b25a19c25095 --- /dev/null +++ b/next/src/pages/zh/cookbooks/[slug].astro @@ -0,0 +1,11 @@ +--- +import EcosystemArticlePage from '../../../components/EcosystemArticlePage.astro'; +import { getCookbooks } from '../../../lib/ecosystem'; + +export function getStaticPaths() { + return getCookbooks('zh').map((resource) => ({ params: { slug: resource.slug }, props: { resource } })); +} + +const { resource } = Astro.props; +--- + diff --git a/next/src/pages/zh/cookbooks/index.astro b/next/src/pages/zh/cookbooks/index.astro new file mode 100644 index 0000000000000..a8ff595dbab6f --- /dev/null +++ b/next/src/pages/zh/cookbooks/index.astro @@ -0,0 +1,4 @@ +--- +import EcosystemCatalogPage from '../../../components/EcosystemCatalogPage.astro'; +--- + diff --git a/next/src/pages/zh/integrations/[slug].astro b/next/src/pages/zh/integrations/[slug].astro new file mode 100644 index 0000000000000..20502a71cb15d --- /dev/null +++ b/next/src/pages/zh/integrations/[slug].astro @@ -0,0 +1,11 @@ +--- +import EcosystemArticlePage from '../../../components/EcosystemArticlePage.astro'; +import { getIntegrations } from '../../../lib/ecosystem'; + +export function getStaticPaths() { + return getIntegrations('zh').map((resource) => ({ params: { slug: resource.slug }, props: { resource } })); +} + +const { resource } = Astro.props; +--- + diff --git a/next/src/pages/zh/integrations/index.astro b/next/src/pages/zh/integrations/index.astro new file mode 100644 index 0000000000000..a32c07489bc22 --- /dev/null +++ b/next/src/pages/zh/integrations/index.astro @@ -0,0 +1,4 @@ +--- +import EcosystemCatalogPage from '../../../components/EcosystemCatalogPage.astro'; +--- + diff --git a/next/tests/e2e/ecosystem-pages.spec.mjs b/next/tests/e2e/ecosystem-pages.spec.mjs new file mode 100644 index 0000000000000..38f0e56996182 --- /dev/null +++ b/next/tests/e2e/ecosystem-pages.spec.mjs @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; + +const pages = [ + '/integrations/', + '/integrations/redis/', + '/cookbooks/', + '/cookbooks/redis-ai-cache/', + '/cookbooks/redis-shared-token-quota/', + '/zh/integrations/', + '/zh/integrations/redis/', + '/zh/cookbooks/', + '/zh/cookbooks/redis-ai-cache/', + '/zh/cookbooks/redis-shared-token-quota/', +]; + +async function expectNoPageOverflow(page) { + const dimensions = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth); +} + +async function jsonLd(page) { + return JSON.parse(await page.locator('script[type="application/ld+json"]').textContent()); +} + +test('all Integration and Cookbook routes are generated', async ({ request }) => { + for (const path of pages) { + const response = await request.get(path); + expect(response.ok(), path).toBeTruthy(); + } +}); + +test('catalogs derive their cards and metadata from Markdown', async ({ page }) => { + await page.goto('/integrations/'); + await expect(page.getByRole('heading', { level: 1, name: 'Connect APISIX to your stack' })).toBeVisible(); + await expect(page.getByTestId('ecosystem-card')).toHaveCount(1); + await expect(page.locator('[data-resource="redis"]')).toContainText('Validation in progress'); + await expect(page.locator('[data-resource="redis"]')).toHaveAttribute('href', '/integrations/redis/'); + await expect(page.locator('[data-resource="redis"] img')).toHaveCount(0); + const integrationSchema = await jsonLd(page); + expect(integrationSchema.find((item) => item['@type'] === 'CollectionPage').mainEntity.numberOfItems).toBe(1); + await expectNoPageOverflow(page); + + await page.goto('/zh/cookbooks/'); + await expect(page.getByRole('heading', { level: 1, name: '运行一个完整场景,而不只是复制配置' })).toBeVisible(); + await expect(page.getByTestId('ecosystem-card')).toHaveCount(2); + await expect(page.locator('[data-resource="redis-ai-cache"]')).toContainText('使用 Redis® 软件的精确与语义匹配缓存 LLM 响应'); + await expectNoPageOverflow(page); +}); + +test('Redis detail pages expose translations, relationships, and source-review boundaries', async ({ page }) => { + test.slow(); + await page.goto('/integrations/redis/'); + await expect(page.getByRole('heading', { level: 1, name: 'Redis® software' })).toBeVisible(); + await expect(page.getByText('Publication gate:')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Cache LLM responses using Redis® software for exact and semantic matching' })) + .toHaveAttribute('href', '/cookbooks/redis-ai-cache/'); + await expect(page.getByRole('link', { name: 'Share an LLM token quota across APISIX nodes with Redis® software' })) + .toHaveAttribute('href', '/cookbooks/redis-shared-token-quota/'); + await expect(page.locator('link[rel="canonical"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/integrations/redis/'); + await expect(page.locator('link[rel="alternate"][hreflang="zh"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/zh/integrations/redis/'); + await expect(page.locator('script:not([type="application/ld+json"])')).toHaveCount(0); + const schema = await jsonLd(page); + expect(schema.some((item) => item['@type'] === 'TechArticle')).toBeTruthy(); + expect(schema.some((item) => item['@type'] === 'BreadcrumbList')).toBeTruthy(); + await expectNoPageOverflow(page); + + await page.goto('/zh/cookbooks/redis-shared-token-quota/'); + await expect(page.getByRole('heading', { level: 1, name: '使用 Redis® 软件在多个 APISIX 节点间共享 LLM token 配额' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Redis® 软件' })).toHaveAttribute('href', '/zh/integrations/redis/'); + await expect(page.locator('link[rel="canonical"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/zh/cookbooks/redis-shared-token-quota/'); + await expectNoPageOverflow(page); +}); diff --git a/website/cookbooks/en/redis-ai-cache.md b/website/cookbooks/en/redis-ai-cache.md new file mode 100644 index 0000000000000..bd5a83b6bd8d1 --- /dev/null +++ b/website/cookbooks/en/redis-ai-cache.md @@ -0,0 +1,120 @@ +--- +title: Cache LLM responses using Redis® software for exact and semantic matching +slug: redis-ai-cache +description: Build and validate an APISIX 3.18 response-cache path with exact hits, semantic matches, tenant isolation, complete-stream checks, and Redis® failure tests. +category: cost +verification: validation-in-progress +owner: Apache APISIX community +difficulty: Intermediate +duration: 45 minutes +apisix_version: 3.18.0 +external_version: Redis® Open Source 8.10.1 +integrations: + - redis +plugins: + - ai-proxy + - ai-cache +reviewed_at: "2026-08-25" +evidence_url: https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache.lua +--- + +This cookbook sets a publication gate stricter than a `MISS` followed by a `HIT` with Redis® software. The gate requires proof that a real model was not called on a hit, semantic matching stays within its documented request shape, tenants cannot reuse each other's entries, incomplete streams are never cached, and a Redis® service outage does not make the LLM path unavailable. + +## Outcome + +From a clean Redis® database, the lab will prove these behaviors against the real OpenAI Chat and Embeddings APIs: + +1. The first identical request is a miss and calls the chat provider once. +2. The second identical request is an exact hit and does not call the chat provider. +3. A calibrated paraphrase is a semantic hit; an unrelated prompt is a miss. +4. When the best-effort L1 backfill succeeds, repeating the paraphrase is an exact hit and does not call the embedding provider again. A backfill error is logged while the semantic hit is still served. +5. Consumer B cannot hit an entry warmed by Consumer A. +6. A complete supported SSE response can be reused, while an interrupted response cannot. +7. When the Redis® service or the embedding endpoint is unavailable, APISIX continues to the real chat provider as a miss. + +## Pinned scope + +The gateway code is pinned to the [APISIX 3.18.0 tag commit](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20). Before this page changes to **E2E verified**, the lab must also record: + +- the immutable APISIX and Redis® image digests; +- the Redis® server version and successful `FT.CREATE`/`FT.SEARCH` smoke test; +- chat and embedding provider names, model identifiers, region, and test time; +- sanitized provider call counters independent of APISIX response headers; +- two clean runs plus a second-operator reproduction. + +No mock or fixture server can satisfy this gate. If provider credentials are unavailable, only the container and Redis® Search preflight may run; the cache result remains unverified. + +[Open the pinned lab](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway). Its `check-infra.sh` can run without provider credentials; `test-cache.sh` requires the real OpenAI Chat and Embeddings APIs. + +## Safe configuration shape + +The tested Route must include all of these boundaries: + +```json +{ + "ai-cache": { + "layers": ["exact", "semantic"], + "cache_key": { + "include_consumer": true + }, + "max_cache_body_size": 1048576, + "redis_host": "redis", + "redis_port": 6379, + "redis_ssl": false, + "semantic": { + "similarity_threshold": 0.95, + "embedding": { + "openai": { + "model": "", + "api_key": "$secret:///" + } + }, + "vector_search": { + "redis": { + "index": "apisix-cookbook-cache" + } + } + } + } +} +``` + +The runnable lab supplies the complete Route, Consumer authentication, provider configuration, private networks, and cleanup. It currently validates response headers and Redis® state; it does not enable or scrape the APISIX Prometheus plugin. The fragment above is the security contract, not a standalone deployment: `include_consumer` only works after APISIX authenticates a Consumer, and a production TLS connection must set `redis_ssl_verify: true`. + +## Acceptance sequence + +| Check | Observable evidence | Failure signal | +|---|---|---| +| Exact cache | `MISS` then `HIT`, byte-identical response body, chat-provider counter delta `+1` then `+0` | Inferring provider calls from the cache header alone | +| Semantic cache | Different prompt returns `HIT` with a similarity header at or above the configured threshold; unrelated prompt returns `MISS` | Hard-coding a similarity score that is not reproduced by the pinned embedding model | +| L2 to L1 backfill | Backfill succeeds; the repeated paraphrase is an exact `HIT` with embedding counter delta `+0` | A backfill warning, or another embedding call on the repeat | +| Tenant isolation | Consumer B gets `MISS`; Consumer A still gets `HIT` | Trusting a caller-supplied tenant header without server-side validation | +| Streaming | Complete SSE is reusable; a deliberately interrupted SSE remains `MISS` on retry | Calling a partial stream cacheable or claiming token-paced replay | +| Failure behavior | A Redis® outage returns a real model response with `MISS`; logs identify the backend failure without request bodies or secrets | Treating `MISS` as proof that the Redis® service is healthy | + +## Measure the result + +Use provider access counters for chat and embedding calls. In a separate deployment where the APISIX Prometheus plugin is enabled and scraped, use these series for cache behavior: + +- `apisix_ai_cache_hits_total{layer="exact"}` +- `apisix_ai_cache_hits_total{layer="semantic"}` +- `apisix_ai_cache_misses_total` +- `apisix_ai_cache_bypasses_total` +- `apisix_ai_cache_embedding_latency_bucket` + +Calculate hit ratio as `hits / (hits + misses)` and report bypass coverage separately. Do not translate cache headers or historical response `usage` fields directly into cost savings. A savings claim requires the same fixed request set with cache disabled and enabled, real provider usage, workload repetition rate, and disclosed sample size. + +## Production boundaries + +- APISIX 3.18 cache storage uses a single Redis® endpoint; this cookbook does not claim Redis® Cluster or Sentinel support for `ai-cache`. +- Semantic matching applies only to plain-text OpenAI Chat requests. Tool calls, multimodal inputs, and other protocols do not become semantically cacheable by configuration. +- A cache hit is not rescanned by a lower-priority guardrail plugin. Do not combine cache and newly changed safety policy without an explicit invalidation plan. +- Never publish full prompts, cached responses, embeddings, API keys, provider request IDs, or full Redis® keys as evidence. + +## Cleanup + +The lab cleanup must delete the APISIX Routes and Consumers, remove only the uniquely prefixed cache index and keys, stop the isolated containers, and confirm no secret-bearing environment file is tracked. It must not flush a shared Redis® database. + +The command assets and captured results will be linked here after the real-service acceptance gate passes. + +Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. This community cookbook is not endorsed, supported, or certified by Redis®. diff --git a/website/cookbooks/en/redis-shared-token-quota.md b/website/cookbooks/en/redis-shared-token-quota.md new file mode 100644 index 0000000000000..0399822b5b39f --- /dev/null +++ b/website/cookbooks/en/redis-shared-token-quota.md @@ -0,0 +1,98 @@ +--- +title: Share an LLM token quota across APISIX nodes with Redis® software +slug: redis-shared-token-quota +description: Validate one post-response LLM token counter across two APISIX 3.18 nodes, including cross-node rejection and explicit Redis® degradation behavior. +category: reliability +verification: validation-in-progress +owner: Apache APISIX community +difficulty: Intermediate +duration: 35 minutes +apisix_version: 3.18.0 +external_version: Redis® Open Source 8.10.1 +integrations: + - redis +plugins: + - ai-proxy + - ai-rate-limiting +reviewed_at: "2026-08-25" +evidence_url: https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-rate-limiting.lua +--- + +This cookbook defines how to verify that two APISIX nodes use one token counter backed by Redis® software; it does not claim E2E verification until the publication gate below passes. It also makes the accounting boundary explicit: APISIX checks the existing counter before a request, but adds the real token usage only after the LLM response. This is a shared upstream-usage quota, not a zero-overshoot budget reservation. + +## Outcome + +The real-service lab must prove: + +1. A request sent to APISIX node A succeeds and its provider-reported token usage is written to the Redis® database. +2. A request sent to node B sees the same remaining quota. +3. The response that crosses the quota may still succeed; the next request on either node receives the configured `429`. +4. A provider response without a usable `usage` object does not increase the counter and is reported as an accounting gap. +5. With `allow_degradation: false`, a pre-request Redis® check failure returns an error rather than silently bypassing quota. +6. With `allow_degradation: true`, the request reaches the real provider and is explicitly classified as unprotected traffic. + +## Pinned scope + +The gateway is fixed to the [APISIX 3.18.0 tag commit](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20). The first verified profile uses the single-node `redis` policy. Redis® Cluster and Redis® Sentinel appear in the plugin schema, but each requires a separate real topology and failover run before this cookbook can claim those profiles as verified. + +The lab must pin immutable APISIX and Redis® image digests, use a real LLM that returns token usage, start from an empty uniquely scoped counter, pass twice, and be reproduced by a second operator. A mock response with a fabricated `usage` field is not E2E evidence. + +[Open the pinned two-node lab](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway). Its infrastructure preflight needs no provider key; `test-shared-quota.sh` requires a real OpenAI response with token usage. + +## Configuration contract + +Use an explicit rejection code and keep the failure policy visible: + +```json +{ + "ai-rate-limiting": { + "limit": 100, + "time_window": 300, + "limit_strategy": "total_tokens", + "policy": "redis", + "redis_host": "redis", + "redis_port": 6379, + "redis_database": 0, + "rejected_code": 429, + "show_limit_quota_header": true, + "allow_degradation": false + } +} +``` + +The complete lab adds the `ai-proxy` provider and credentials through secret references, gives both APISIX nodes the same Route and Redis® configuration, and exposes only their gateway ports. Admin, Control API, and Redis® ports stay on private networks. + +## Acceptance sequence + +| Step | Node | Expected evidence | +|---|---|---| +| Baseline | A | HTTP 200, real provider response, quota headers, provider `usage`, and one corresponding Redis® counter increase | +| Cross-node read | B | Remaining quota reflects node A's usage rather than a fresh local budget | +| Cross the limit | A or B | The crossing response may be HTTP 200; the committed Redis® value exceeds the configured limit | +| Enforce | Other node | The next request is HTTP 429 and provider call count does not increase | +| Reset | Both | Only after the fixed window expires do both nodes accept traffic under a new counter window | +| Pre-request fail closed | A | The Redis® service is unavailable before the quota check, so degradation disabled returns an error and provider count stays unchanged | +| Degrade open | B | The Redis® service is unavailable before the quota check, so degradation enabled reaches the provider; evidence labels it as not quota-protected | + +Poll the Redis® counter after every successful model response before issuing the next assertion. Otherwise, response-log timing can make a valid post-response write look missing. + +## What the headers do not prove + +Quota headers are useful client feedback, but they are not independent accounting evidence. Capture the response headers, provider `usage`, Redis® value and TTL, APISIX error log, and provider call delta for the same request. Do not expose the complete Redis® key if it contains Consumer, Route, or model identifiers. + +## Production boundaries + +- This fixed-window counter does not reserve the prompt's worst-case completion tokens before sending the request. Concurrent requests can overshoot. +- When the provider does not return token usage, the response cannot be charged by this mechanism. +- `allow_degradation: true` preserves availability by removing quota protection during a Redis® fault. Alert on that state. +- `allow_degradation: false` only fails closed for a Redis® error observed by the pre-request check. A failure in the asynchronous post-response write cannot retract the response and can leave usage uncommitted; alert on APISIX write errors and reconcile against provider usage. +- `ai-cache` hits return before the rate-limiter runs and do not consume this upstream token quota. +- For a per-tenant quota, authenticate the caller and configure `rules.key` from a trusted server-side identity such as `$consumer_name`. Authentication alone does not partition the default constant counter. Do not base a paid quota on a caller-controlled header. + +## Cleanup + +Delete the lab Routes, remove only the uniquely scoped Redis® counter keys, stop both APISIX nodes and the Redis® service, and verify no credential file is tracked. Do not use `FLUSHALL` against a shared Redis® service. + +The command assets and sanitized evidence will be linked here after the real-service acceptance gate passes. + +Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. This community cookbook is not endorsed, supported, or certified by Redis®. diff --git a/website/cookbooks/zh/redis-ai-cache.md b/website/cookbooks/zh/redis-ai-cache.md new file mode 100644 index 0000000000000..bee5b1961e097 --- /dev/null +++ b/website/cookbooks/zh/redis-ai-cache.md @@ -0,0 +1,108 @@ +--- +title: 使用 Redis® 软件的精确与语义匹配缓存 LLM 响应 +slug: redis-ai-cache +translation_of: redis-ai-cache +description: 构建并验证 APISIX 3.18 响应缓存链路,覆盖精确命中、语义匹配、租户隔离、完整流检查和 Redis® 故障测试。 +difficulty: 中等 +--- + +本 Cookbook 的发布门不只要求 Redis® 软件上的一次 `MISS` 和一次 `HIT`。它还要求证明:命中时真实模型没有被调用;语义匹配不超出文档支持的请求形态;不同租户不能复用彼此缓存;不完整的流永远不会写入缓存;Redis® 服务故障也不会让 LLM 链路不可用。 + +## 预期结果 + +从空 Redis® 数据库开始,实验将使用真实 OpenAI Chat 与 Embeddings API 验证: + +1. 第一个相同请求未命中,并调用一次 chat Provider。 +2. 第二个相同请求精确命中,不再调用 chat Provider。 +3. 经过校准的改写问题语义命中,无关问题未命中。 +4. best-effort L1 回填成功后,再次发送改写问题会精确命中,且不再调用 embedding Provider。回填出错时会记录日志,但当前语义命中仍会返回。 +5. Consumer B 不能命中 Consumer A 预热的缓存。 +6. 完整、受支持的 SSE 可复用,中断响应不能复用。 +7. Redis® 服务或 embedding endpoint 不可用时,APISIX 仍以缓存未命中方式调用真实 chat Provider。 + +## 固定范围 + +网关源码固定到 [APISIX 3.18.0 标签提交](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20)。页面变为“**端到端已验证**”前,实验还必须记录: + +- APISIX 与 Redis® 不可变镜像 digest; +- Redis® 服务端版本,以及成功的 `FT.CREATE`/`FT.SEARCH` 预检; +- chat 与 embedding Provider、模型标识、区域和测试时间; +- 独立于 APISIX 响应 header 的脱敏 Provider 调用计数; +- 两次干净运行与第二位操作者复现。 + +mock 或 fixture server 不能满足发布门。没有 Provider 凭据时,只能验证容器与 Redis® Search 预检,不能把缓存结果标记为已验证。 + +[打开固定版本实验](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway)。`check-infra.sh` 无需 Provider 凭据即可运行;`test-cache.sh` 需要真实 OpenAI Chat 与 Embeddings API。 + +## 安全配置形态 + +被测试的 Route 必须包含以下边界: + +```json +{ + "ai-cache": { + "layers": ["exact", "semantic"], + "cache_key": { + "include_consumer": true + }, + "max_cache_body_size": 1048576, + "redis_host": "redis", + "redis_port": 6379, + "redis_ssl": false, + "semantic": { + "similarity_threshold": 0.95, + "embedding": { + "openai": { + "model": "<固定的-embedding-model>", + "api_key": "$secret:///" + } + }, + "vector_search": { + "redis": { + "index": "apisix-cookbook-cache" + } + } + } + } +} +``` + +可运行实验会补全 Route、Consumer 认证、Provider 配置、私有网络和清理步骤。目前它通过响应 header 与 Redis® 状态验证,并未启用或抓取 APISIX Prometheus 插件。上面的片段是安全契约,并非独立部署配置:`include_consumer` 只有在 APISIX 已认证 Consumer 后才生效;生产 TLS 连接必须设置 `redis_ssl_verify: true`。 + +## 验收顺序 + +| 检查 | 可观察证据 | 失败信号 | +|---|---|---| +| 精确缓存 | `MISS` 后 `HIT`,缓存响应正文字节一致,chat Provider 计数依次为 `+1`、`+0` | 仅根据缓存 header 推测 Provider 调用数 | +| 语义缓存 | 不同问题返回带 similarity header 的 `HIT`,数值不低于阈值;无关问题返回 `MISS` | 固定写死无法由当前 embedding 模型复现的分数 | +| L2 回填 L1 | 回填成功;再次发送改写问题是精确 `HIT`,embedding 计数增量为 `+0` | 出现回填告警,或重复请求再次调用 embedding 服务 | +| 租户隔离 | Consumer B 得到 `MISS`,Consumer A 仍为 `HIT` | 只依赖调用方可伪造的租户 header | +| 流式响应 | 完整 SSE 可复用;主动中断的 SSE 重试仍为 `MISS` | 把 partial stream 称为可缓存,或宣称命中后仍按 token 节奏回放 | +| 故障行为 | Redis® 服务停止后仍由真实模型返回响应并显示 `MISS`;日志能识别后端故障但不含正文和密钥 | 把 `MISS` 当作 Redis® 服务健康证明 | + +## 结果度量 + +chat 和 embedding 调用数必须来自 Provider 侧计数。在另行启用并抓取 APISIX Prometheus 插件的部署中,可使用以下 series 观察缓存行为: + +- `apisix_ai_cache_hits_total{layer="exact"}` +- `apisix_ai_cache_hits_total{layer="semantic"}` +- `apisix_ai_cache_misses_total` +- `apisix_ai_cache_bypasses_total` +- `apisix_ai_cache_embedding_latency_bucket` + +命中率按 `hits / (hits + misses)` 计算,绕过覆盖率单独报告。不要直接把缓存 header 或历史响应中的 `usage` 转换为成本节省。成本结论必须使用同一固定请求集分别关闭和启用缓存,并披露真实 Provider usage、工作负载重复率和样本数。 + +## 生产边界 + +- APISIX 3.18 的缓存后端只支持一个 Redis® endpoint;本文不声称 `ai-cache` 支持 Redis® Cluster 或 Sentinel。 +- 语义匹配仅适用于纯文本 OpenAI Chat。tool call、多模态输入和其他协议不会因配置而自动支持语义缓存。 +- 缓存命中不会再经过优先级更低的 Guardrail 插件。安全策略变化后若没有明确的失效方案,不要直接组合使用。 +- 证据中不得公开完整 prompt、缓存响应、embedding、API key、Provider request ID 或完整 Redis® key。 + +## 清理 + +实验清理必须删除 APISIX Route 和 Consumer,只删除带唯一前缀的缓存索引与 key,停止隔离容器,并确认含密钥的环境文件没有被 Git 跟踪。不得清空共享 Redis® 数据库。 + +真实服务验收通过后,本页会补充命令资产和脱敏结果链接。 + +Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. 此社区 Cookbook 未获得 Redis® 的认可、支持或认证。 diff --git a/website/cookbooks/zh/redis-shared-token-quota.md b/website/cookbooks/zh/redis-shared-token-quota.md new file mode 100644 index 0000000000000..997a8c7f5b46e --- /dev/null +++ b/website/cookbooks/zh/redis-shared-token-quota.md @@ -0,0 +1,86 @@ +--- +title: 使用 Redis® 软件在多个 APISIX 节点间共享 LLM token 配额 +slug: redis-shared-token-quota +translation_of: redis-shared-token-quota +description: 验证两个 APISIX 3.18 节点共享同一个响应后 LLM token 计数,包括跨节点拒绝和明确的 Redis® 降级行为。 +difficulty: 中等 +--- + +本 Cookbook 定义如何验证两个 APISIX 节点使用同一个以 Redis® 软件为后端的 token 计数;在下述发布门通过前,不声称已完成端到端验证。同时明确记账边界:APISIX 在请求前检查已有计数,但只有在 LLM 返回后才加入真实 token usage。这是共享的上游用量配额,并非零超额的预算预留。 + +## 预期结果 + +真实服务实验必须证明: + +1. 发往 APISIX 节点 A 的请求成功,Provider 返回的 token usage 被写入 Redis® 数据库。 +2. 发往节点 B 的请求看到同一份剩余额度。 +3. 使额度越界的响应仍可能成功;之后发往任一节点的请求才返回配置的 `429`。 +4. Provider 响应没有可用 `usage` 时,计数不会增加,并明确记录为记账缺口。 +5. `allow_degradation: false` 时,请求前 Redis® 检查失败会返回错误,而不是静默绕过配额。 +6. `allow_degradation: true` 时,请求会到达真实 Provider,并被明确标记为没有配额保护的流量。 + +## 固定范围 + +网关固定到 [APISIX 3.18.0 标签提交](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20)。首个验证 profile 使用单节点 `redis` policy。插件 Schema 还包含 Redis® Cluster 与 Redis® Sentinel,但只有各自在真实拓扑和故障切换实验通过后,本文才能把它们标记为已验证。 + +实验必须固定 APISIX 与 Redis® 的不可变镜像 digest,使用会返回 token usage 的真实 LLM,从空的唯一作用域计数开始,连续通过两次,并由第二位操作者复现。带伪造 `usage` 的 mock 响应不是端到端证据。 + +[打开固定版本的双节点实验](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway)。基础设施预检无需 Provider key;`test-shared-quota.sh` 需要真实 OpenAI 响应中的 token usage。 + +## 配置契约 + +显式设置拒绝状态码,并让故障策略保持可见: + +```json +{ + "ai-rate-limiting": { + "limit": 100, + "time_window": 300, + "limit_strategy": "total_tokens", + "policy": "redis", + "redis_host": "redis", + "redis_port": 6379, + "redis_database": 0, + "rejected_code": 429, + "show_limit_quota_header": true, + "allow_degradation": false + } +} +``` + +完整实验会通过 Secret 引用补充 `ai-proxy` Provider 与凭据,为两个 APISIX 节点下发相同 Route 和 Redis® 配置,并只暴露 Gateway 端口。Admin、Control API 与 Redis® 端口都保留在私有网络。 + +## 验收顺序 + +| 步骤 | 节点 | 预期证据 | +|---|---|---| +| 基线 | A | HTTP 200、真实 Provider 响应、配额 header、Provider `usage`,以及一次对应的 Redis® 计数增加 | +| 跨节点读取 | B | 剩余额度包含节点 A 的用量,而不是一份新的本地预算 | +| 越过阈值 | A 或 B | 越界响应可能仍为 HTTP 200;Redis® 数据库中已提交的数值超过配置阈值 | +| 执行拒绝 | 另一个节点 | 下一个请求返回 HTTP 429,Provider 调用数不增加 | +| 窗口重置 | 两个节点 | 固定窗口过期后,两边才会在新窗口内重新接受流量 | +| 请求前故障关闭 | A | Redis® 服务在配额检查前不可用,禁用降级时返回错误,Provider 调用数不增加 | +| 故障放行 | B | Redis® 服务在配额检查前不可用,启用降级时到达 Provider;证据明确标注此请求不受配额保护 | + +每次模型成功响应后,都要轮询 Redis® 计数再执行下一条断言。否则,响应日志与记账时序可能让合法的响应后写入看起来像缺失。 + +## Header 不能单独证明什么 + +配额 header 适合给客户端反馈,但不是独立的记账证据。应为同一请求同时保存响应 header、Provider `usage`、Redis® value 与 TTL、APISIX error log 和 Provider 调用增量。若完整 Redis® key 包含 Consumer、Route 或模型标识,不得公开。 + +## 生产边界 + +- 固定窗口计数不会在请求上游前预留 prompt 的最大 completion token;并发请求可能越界。 +- Provider 不返回 token usage 时,此机制无法给响应记账。 +- `allow_degradation: true` 通过在 Redis® 故障时移除配额保护来保持可用性,必须对这一状态告警。 +- `allow_degradation: false` 只会对请求前检查观测到的 Redis® 错误执行故障关闭。异步响应后写入失败无法撤回当前响应,并可能使 usage 未提交;必须对 APISIX 写入错误告警,并与 Provider usage 对账。 +- `ai-cache` 命中会在限流插件之前返回,不消耗这份上游 token 配额。 +- 每租户配额必须先认证调用方,并用可信的服务端身份(例如 `$consumer_name`)配置 `rules.key`。仅启用认证不会拆分默认的 constant 计数;付费配额不得使用调用方可伪造的 header 作为 key。 + +## 清理 + +删除实验 Route,只删除带唯一作用域的 Redis® counter key,停止两个 APISIX 节点与 Redis® 服务,并确认没有凭据文件被 Git 跟踪。不得对共享 Redis® 服务执行 `FLUSHALL`。 + +真实服务验收通过后,本页会补充命令资产和脱敏证据链接。 + +Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. 此社区 Cookbook 未获得 Redis® 的认可、支持或认证。 diff --git a/website/i18n/zh/docusaurus-theme-classic/navbar.json b/website/i18n/zh/docusaurus-theme-classic/navbar.json index 7118d332d389f..2951d82f70cab 100644 --- a/website/i18n/zh/docusaurus-theme-classic/navbar.json +++ b/website/i18n/zh/docusaurus-theme-classic/navbar.json @@ -19,6 +19,14 @@ "message": "相关资源", "description": "Navbar item with label Resources" }, + "item.label.Integrations": { + "message": "集成", + "description": "Navbar item with label Integrations" + }, + "item.label.Cookbooks": { + "message": "Cookbook", + "description": "Navbar item with label Cookbooks" + }, "item.label.PluginHub": { "message": "插件市场", "description": "Navbar item with label Plugin Hub" diff --git a/website/integrations/en/redis.md b/website/integrations/en/redis.md new file mode 100644 index 0000000000000..7f64324e9ba24 --- /dev/null +++ b/website/integrations/en/redis.md @@ -0,0 +1,61 @@ +--- +title: Redis® software +slug: redis +description: Use Redis® software as the exact and semantic response-cache backend for APISIX AI Gateway, or as the shared token-counter backend across APISIX nodes. +category: data +method: Built-in APISIX plugins +verification: validation-in-progress +owner: Apache APISIX community +apisix_version: 3.18.0 +external_version: Redis® Open Source 8.10.1 +protocols: + - RESP +reviewed_at: "2026-08-25" +evidence_url: https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache.lua +--- + +Apache APISIX 3.18 has two separate paths backed by Redis® software: `ai-cache` stores reusable LLM responses, while `ai-rate-limiting` shares post-response token counters across APISIX nodes. They have different topology and failure behavior, so choose and operate them independently. + +
+ AI clientApache APISIXRedis® software +
+ +## Capability scope + +| Capability | APISIX 3.18 behavior | Important boundary | +|---|---|---| +| Exact response cache | Redis® software stores the body of an HTTP 200 AI response keyed by a normalized effective request. The default TTL is 3,600 seconds and the default maximum response is 1 MiB. | The cache policy supports one Redis® endpoint in 3.18. It does not provide request coalescing, honor upstream `Cache-Control`, or expose a dedicated purge API. A hit reconstructs HTTP 200 and `Content-Type`; other upstream response headers are not stored or replayed. | +| Semantic response cache | After an exact miss, APISIX can embed a plain-text OpenAI Chat prompt and query Redis® Search for a similar cached response. | Semantic matching is limited to plain-text OpenAI Chat requests. Multimodal requests and non-empty tool or function calls bypass this layer. The exact layer remains enabled. | +| Shared token quota | `ai-rate-limiting` can store a fixed-window token counter in a Redis® database so multiple APISIX nodes see the same usage. | Accounting happens after a model response supplies token usage. It is not a prepaid reservation: a large or concurrent response can cross the limit before a later request is rejected. | +| Streaming cache | A complete supported SSE response can be cached after APISIX sees the protocol terminal event. | Interrupted streams are not cached. JSON and SSE use separate entries. A cache hit replays the complete stored SSE immediately; it does not reproduce the original token cadence. | + +Semantic caching requires Redis® Search commands. The companion lab pins Redis® Open Source 8.10.1, and its infrastructure preflight checks that `FT._LIST` is available. That check is infrastructure evidence only; it does not verify the LLM cache path. For an earlier Redis® Open Source or Redis® Stack release, pin and test the exact version rather than assuming compatibility. + +## Isolation and security + +Cache entries are isolated by Route by default, but Consumers on the same Route can share them. For multi-tenant traffic, first authenticate each tenant as a distinct Consumer and then set `cache_key.include_consumer: true`, or include a trusted server-side tenant variable. The option alone does not isolate unauthenticated traffic, and a client-controlled header is not a tenant boundary. + +Redis® credentials should use an APISIX secret reference. Keep the Redis® endpoint on a private network, enable TLS certificate verification where TLS is used, and do not expose cached prompts, embeddings, provider request IDs, or full Redis® keys in logs or screenshots. + +## Failure behavior + +- Cache, vector-search, and embedding errors degrade to a cache miss, so APISIX continues to the LLM. A `MISS` header alone does not prove the Redis® service is healthy. +- Shared quota is different. A pre-request Redis® quota check failure returns an error when `allow_degradation: false`, or lets the request continue without quota protection when it is `true`. This setting cannot fail closed after the LLM response: if the Redis® service fails between the access check and the asynchronous log-phase counter write, the response can succeed and the token increment can remain uncommitted; alert on write errors. +- A cache hit returns before `ai-rate-limiting` runs. It avoids an upstream model call and does not increase the Redis® token counter. + +## Observability + +The APISIX Prometheus plugin exports cache hits by `exact` or `semantic` layer, misses, bypasses, and embedding-latency histograms. `ai-rate-limiting` does not export a dedicated Redis® token-counter metric; validate it with response headers, Redis® state, APISIX logs, and provider usage together. + +## Source-reviewed references + +- [`ai-cache` source at the APISIX 3.18.0 tag](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache.lua) +- [`ai-cache` schema](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/schema.lua) +- [Semantic-cache implementation](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/semantic.lua) +- [`ai-rate-limiting` source](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-rate-limiting.lua) +- [Redis® Search module lifecycle](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/modules-lifecycle/) +- [Pinned two-node APISIX and Redis® lab](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway) + +This page remains **Validation in progress** until its pinned lab passes twice from a clean state and a second operator reproduces it. Source review establishes the intended 3.18.0 behavior; it is not runtime verification. + +Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. This community integration is not endorsed, supported, or certified by Redis®. diff --git a/website/integrations/zh/redis.md b/website/integrations/zh/redis.md new file mode 100644 index 0000000000000..78566a1901210 --- /dev/null +++ b/website/integrations/zh/redis.md @@ -0,0 +1,53 @@ +--- +title: Redis® 软件 +slug: redis +translation_of: redis +description: 使用 Redis® 软件作为 APISIX AI Gateway 的精确与语义响应缓存后端,或在多个 APISIX 节点间共享 token 计数。 +method: APISIX 内置插件 +--- + +Apache APISIX 3.18 提供两条彼此独立、以 Redis® 软件为后端的集成路径:`ai-cache` 保存可复用的 LLM 响应,`ai-rate-limiting` 则在多个 APISIX 节点之间共享响应后的 token 计数。两者的拓扑与失败行为不同,应分别选择和运维。 + +
+ AI 客户端Apache APISIXRedis® 软件 +
+ +## 能力范围 + +| 能力 | APISIX 3.18 行为 | 重要边界 | +|---|---|---| +| 精确响应缓存 | Redis® 软件按规范化后的有效 AI 请求保存 HTTP 200 AI 响应的正文。默认 TTL 为 3,600 秒,默认最大响应为 1 MiB。 | 3.18 的缓存策略只支持一个 Redis® 地址;没有请求合并,不处理上游 `Cache-Control`,也没有专用清理 API。命中会重建 HTTP 200 与 `Content-Type`;其他上游响应 header 不会被保存或回放。 | +| 语义响应缓存 | 精确缓存未命中后,APISIX 可为纯文本 OpenAI Chat prompt 生成 embedding,并通过 Redis® Search 查找相似响应。 | 语义匹配只适用于纯文本 OpenAI Chat。多模态请求和非空 tool/function call 会绕过这一层;精确缓存层始终启用。 | +| 共享 token 配额 | `ai-rate-limiting` 可把固定窗口计数保存在 Redis® 数据库中,使多个 APISIX 节点看到同一份用量。 | 只有模型响应给出 usage 后才记账,并非预付式额度预留;大响应或并发响应可能先越过阈值,后续请求才被拒绝。 | +| 流式缓存 | APISIX 识别到协议终止事件后,可缓存完整、受支持的 SSE 响应。 | 中断的流不会写入缓存;JSON 与 SSE 使用不同条目;命中时会立即回放完整 SSE,不会复现原始 token 节奏。 | + +语义缓存需要 Redis® Search 命令。配套实验固定 Redis® Open Source 8.10.1,其基础设施预检会检查 `FT._LIST` 是否可用。该检查仅属于基础设施证据,不能验证 LLM 缓存链路。若使用更早的 Redis® Open Source 或 Redis® Stack 版本,应固定并验证精确版本,不要默认兼容。 + +## 隔离与安全 + +缓存默认按 Route 隔离,但同一 Route 上的不同 Consumer 可能共享缓存。多租户流量必须先把各租户认证为不同 Consumer,再配置 `cache_key.include_consumer: true`;也可以加入可信的服务端租户变量。该选项本身不能隔离未认证流量,仅使用客户端可伪造的 header 也不能构成租户边界。 + +Redis® 凭据应使用 APISIX Secret 引用。Redis® 服务应位于私有网络;使用 TLS 时必须校验证书;日志和截图中不要暴露缓存正文、embedding、Provider request ID 或完整 Redis® key。 + +## 失败行为 + +- 缓存、向量搜索或 embedding 出错时会降级为缓存未命中,APISIX 继续请求 LLM。因此,仅看到 `MISS` header 不能证明 Redis® 服务健康。 +- 共享配额的行为不同:请求前 Redis® 配额检查失败时,`allow_degradation: false` 返回错误,设为 `true` 则在无配额保护的情况下继续。该设置无法对响应后记账失败执行关闭:若 Redis® 服务在 access 检查与异步 log 阶段计数写入之间故障,当前响应仍可能成功且 token 增量可能未提交;必须对写入错误告警。 +- 缓存命中会在 `ai-rate-limiting` 之前直接返回,因此不会调用上游模型,也不会增加 Redis® token 计数。 + +## 可观测性 + +APISIX Prometheus 插件会导出按 `exact` 或 `semantic` 分层的命中数、未命中数、绕过数和 embedding 延迟直方图。`ai-rate-limiting` 没有专用的 Redis® token-counter 指标,应结合响应 header、Redis® 状态、APISIX 日志和 Provider usage 进行验证。 + +## 已核对的源码 + +- [APISIX 3.18.0 标签中的 `ai-cache` 源码](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache.lua) +- [`ai-cache` Schema](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/schema.lua) +- [语义缓存实现](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/semantic.lua) +- [`ai-rate-limiting` 源码](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-rate-limiting.lua) +- [Redis® Search 模块生命周期](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/modules-lifecycle/) +- [固定版本的双节点 APISIX 与 Redis® 实验](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway) + +在固定版本实验从干净状态连续通过两次,并由第二位操作者复现前,本页面保持“**验证进行中**”。源码核对只能证明 3.18.0 的预期行为,不能替代运行时验证。 + +Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. 此社区集成未获得 Redis® 的认可、支持或认证。 diff --git a/website/static/llms.txt b/website/static/llms.txt index 1e688d3a79434..1231c6c0e6f6c 100644 --- a/website/static/llms.txt +++ b/website/static/llms.txt @@ -34,6 +34,14 @@ - [AI Proxy Plugin](https://apisix.apache.org/docs/apisix/plugins/ai-proxy/): Route requests to 20+ LLM providers - [AI RAG Plugin](https://apisix.apache.org/docs/apisix/plugins/ai-rag/): Retrieval-augmented generation support +## Integrations & Cookbooks + +- [Integration Hub](https://apisix.apache.org/integrations/): Versioned connections between Apache APISIX and external products +- [Apache APISIX with Redis](https://apisix.apache.org/integrations/redis/): Redis-backed AI response caching and shared token counters +- [Cookbooks](https://apisix.apache.org/cookbooks/): Reproducible outcome-focused APISIX guides +- [Redis exact and semantic AI cache](https://apisix.apache.org/cookbooks/redis-ai-cache/): Validate exact hits, semantic matches, isolation, and failures +- [Shared Redis token quota](https://apisix.apache.org/cookbooks/redis-shared-token-quota/): Validate a token counter across APISIX nodes + ## Observability - [Prometheus](https://apisix.apache.org/docs/apisix/plugins/prometheus/): Metrics export for monitoring From 99ae070368f3d1df311d9e1b0e154ea320b9b069 Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Thu, 27 Aug 2026 10:28:52 +0800 Subject: [PATCH 2/8] test(ecosystem): cover navigation journey --- next/tests/e2e/ecosystem-pages.spec.mjs | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/next/tests/e2e/ecosystem-pages.spec.mjs b/next/tests/e2e/ecosystem-pages.spec.mjs index 38f0e56996182..6c53ca13541e1 100644 --- a/next/tests/e2e/ecosystem-pages.spec.mjs +++ b/next/tests/e2e/ecosystem-pages.spec.mjs @@ -32,6 +32,32 @@ test('all Integration and Cookbook routes are generated', async ({ request }) => } }); +test('header navigation reaches an Integration and its Cookbook', async ({ page }, testInfo) => { + await page.goto('/'); + + if (testInfo.project.name === 'mobile-chrome') { + await page.locator('.mobile-toggle > summary').click(); + const ecosystem = page.locator('.mobile-nav-group').filter({ hasText: 'Ecosystem' }); + await ecosystem.locator('summary').click(); + await ecosystem.getByRole('link', { name: 'Integrations', exact: true }).click(); + } else { + const ecosystem = page.locator('.main-nav .nav-drop').filter({ hasText: 'Ecosystem' }); + await ecosystem.locator('summary').click(); + await ecosystem.getByRole('link', { name: 'Integrations', exact: true }).click(); + } + + await expect(page).toHaveURL(/\/integrations\/$/); + await page.locator('[data-resource="redis"]').click(); + await expect(page.getByRole('heading', { level: 1, name: 'Redis® software' })).toBeVisible(); + await page.getByRole('link', { + name: 'Cache LLM responses using Redis® software for exact and semantic matching', + }).click(); + await expect(page.getByRole('heading', { + level: 1, + name: 'Cache LLM responses using Redis® software for exact and semantic matching', + })).toBeVisible(); +}); + test('catalogs derive their cards and metadata from Markdown', async ({ page }) => { await page.goto('/integrations/'); await expect(page.getByRole('heading', { level: 1, name: 'Connect APISIX to your stack' })).toBeVisible(); From feb06758b0b708dea3dd0d1653709cff85469aad Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Thu, 27 Aug 2026 10:30:13 +0800 Subject: [PATCH 3/8] fix(navigation): preserve mobile Docs entry --- next/src/components/Header.astro | 3 +++ next/tests/e2e/ecosystem-pages.spec.mjs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/next/src/components/Header.astro b/next/src/components/Header.astro index bee849024a4f6..0c16cc8c50083 100644 --- a/next/src/components/Header.astro +++ b/next/src/components/Header.astro @@ -51,6 +51,9 @@ const switchUrl = switchPath ?? (locale === 'zh' ? path : `/zh${path}`);
{label(item)}
+ {!item.items.some((sub) => sub.href === item.href) && ( + {label(item)} + )} {item.items.map((sub) => {label(sub)})}
diff --git a/next/tests/e2e/ecosystem-pages.spec.mjs b/next/tests/e2e/ecosystem-pages.spec.mjs index 6c53ca13541e1..b2fabc9d88a4c 100644 --- a/next/tests/e2e/ecosystem-pages.spec.mjs +++ b/next/tests/e2e/ecosystem-pages.spec.mjs @@ -37,6 +37,9 @@ test('header navigation reaches an Integration and its Cookbook', async ({ page if (testInfo.project.name === 'mobile-chrome') { await page.locator('.mobile-toggle > summary').click(); + const docs = page.locator('.mobile-nav-group').filter({ hasText: 'Docs' }); + await docs.locator('summary').click(); + await expect(docs.getByRole('link', { name: 'Docs', exact: true })).toHaveAttribute('href', '/docs/'); const ecosystem = page.locator('.mobile-nav-group').filter({ hasText: 'Ecosystem' }); await ecosystem.locator('summary').click(); await ecosystem.getByRole('link', { name: 'Integrations', exact: true }).click(); From 79f5f317a780831b5b009743b3113dace34ba772 Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Thu, 27 Aug 2026 10:34:40 +0800 Subject: [PATCH 4/8] fix(ecosystem): address review boundaries --- examples/redis-ai-gateway/README.md | 2 +- examples/redis-ai-gateway/conf/apisix.yaml | 36 ++++++++++++++++--- .../src/components/EcosystemCatalogPage.astro | 6 +++- next/src/layouts/EcosystemDetail.astro | 2 +- next/src/lib/ecosystem.ts | 2 +- next/tests/e2e/ecosystem-pages.spec.mjs | 3 ++ website/cookbooks/en/redis-ai-cache.md | 2 ++ website/cookbooks/zh/redis-ai-cache.md | 3 ++ .../cookbooks/zh/redis-shared-token-quota.md | 1 + website/integrations/en/redis.md | 4 +-- website/integrations/zh/redis.md | 4 +-- 11 files changed, 53 insertions(+), 12 deletions(-) diff --git a/examples/redis-ai-gateway/README.md b/examples/redis-ai-gateway/README.md index 004b3158226f2..31c6f4a7a8cbf 100644 --- a/examples/redis-ai-gateway/README.md +++ b/examples/redis-ai-gateway/README.md @@ -34,7 +34,7 @@ This preflight does **not** call an LLM and is not cache or quota E2E evidence. Add a dedicated, least-privilege OpenAI API key to `.env`. The tests use `gpt-4o-mini` and `text-embedding-3-small` by default. They never print the key or prompt/response bodies. -The Routes remove successful key-auth credentials before proxying, and the lab access-log format excludes raw query strings. Send the Consumer key in the `apikey` header; do not place credentials in URLs. +The Routes remove successful key-auth credentials and client-supplied OpenAI organization, project, and beta-selection headers before proxying. This keeps forwarded headers that can change the provider response outside the lab's cache boundary. The lab access-log format excludes raw query strings. Send the Consumer key in the `apikey` header; do not place credentials in URLs. ```dotenv OPENAI_API_KEY=replace-me diff --git a/examples/redis-ai-gateway/conf/apisix.yaml b/examples/redis-ai-gateway/conf/apisix.yaml index 226cd9f53fbda..1bbe328e794aa 100644 --- a/examples/redis-ai-gateway/conf/apisix.yaml +++ b/examples/redis-ai-gateway/conf/apisix.yaml @@ -17,7 +17,14 @@ routes: hide_credentials: true proxy-rewrite: headers: - remove: [Authorization, Cookie, X-API-Key, apikey] + remove: + - Authorization + - Cookie + - X-API-Key + - apikey + - OpenAI-Organization + - OpenAI-Project + - OpenAI-Beta ai-proxy: provider: openai auth: @@ -57,7 +64,14 @@ routes: hide_credentials: true proxy-rewrite: headers: - remove: [Authorization, Cookie, X-API-Key, apikey] + remove: + - Authorization + - Cookie + - X-API-Key + - apikey + - OpenAI-Organization + - OpenAI-Project + - OpenAI-Beta ai-proxy: provider: openai auth: @@ -94,7 +108,14 @@ routes: hide_credentials: true proxy-rewrite: headers: - remove: [Authorization, Cookie, X-API-Key, apikey] + remove: + - Authorization + - Cookie + - X-API-Key + - apikey + - OpenAI-Organization + - OpenAI-Project + - OpenAI-Beta ai-proxy: provider: openai auth: @@ -133,7 +154,14 @@ routes: hide_credentials: true proxy-rewrite: headers: - remove: [Authorization, Cookie, X-API-Key, apikey] + remove: + - Authorization + - Cookie + - X-API-Key + - apikey + - OpenAI-Organization + - OpenAI-Project + - OpenAI-Beta ai-proxy: provider: openai auth: diff --git a/next/src/components/EcosystemCatalogPage.astro b/next/src/components/EcosystemCatalogPage.astro index b8704aa3e1d76..e36ba23bca61b 100644 --- a/next/src/components/EcosystemCatalogPage.astro +++ b/next/src/components/EcosystemCatalogPage.astro @@ -79,7 +79,11 @@ const collectionSchema = {
-

{isIntegrations ? 'Integration Hub' : 'Runnable Cookbooks'}

+

+ {isIntegrations + ? t(locale, 'Integration Hub', '集成中心') + : t(locale, 'Runnable Cookbooks', '可运行 Cookbook')} +

{heading}

{description}

diff --git a/next/src/layouts/EcosystemDetail.astro b/next/src/layouts/EcosystemDetail.astro index 08922ca5c2fa2..f4cdea13ad2ec 100644 --- a/next/src/layouts/EcosystemDetail.astro +++ b/next/src/layouts/EcosystemDetail.astro @@ -50,7 +50,7 @@ const p = localePrefix(locale); const hubPath = kind === 'integration' ? '/integrations/' : '/cookbooks/'; const hubLabel = kind === 'integration' ? t(locale, 'Integrations', '集成') - : 'Cookbooks'; + : t(locale, 'Cookbooks', 'Cookbook'); const articleSchema = { '@context': 'https://schema.org', diff --git a/next/src/lib/ecosystem.ts b/next/src/lib/ecosystem.ts index 7b3e78f177b1a..8c5f9a6afe423 100644 --- a/next/src/lib/ecosystem.ts +++ b/next/src/lib/ecosystem.ts @@ -186,7 +186,7 @@ function cookbookFromModule( description: requiredString(translated, 'description'), category: cookbookCategory(enMod), difficulty: requiredString(translated, 'difficulty'), - duration: requiredString(enMod, 'duration'), + duration: requiredString(translated, 'duration'), verification: status, owner: requiredString(enMod, 'owner'), apisixVersion: requiredString(enMod, 'apisix_version'), diff --git a/next/tests/e2e/ecosystem-pages.spec.mjs b/next/tests/e2e/ecosystem-pages.spec.mjs index b2fabc9d88a4c..511056de305e3 100644 --- a/next/tests/e2e/ecosystem-pages.spec.mjs +++ b/next/tests/e2e/ecosystem-pages.spec.mjs @@ -100,6 +100,9 @@ test('Redis detail pages expose translations, relationships, and source-review b await page.goto('/zh/cookbooks/redis-shared-token-quota/'); await expect(page.getByRole('heading', { level: 1, name: '使用 Redis® 软件在多个 APISIX 节点间共享 LLM token 配额' })).toBeVisible(); + await expect(page.locator('.breadcrumbs').getByRole('link', { name: 'Cookbook', exact: true })) + .toHaveAttribute('href', '/zh/cookbooks/'); + await expect(page.locator('.resource-facts')).toContainText('35 分钟'); await expect(page.getByRole('link', { name: 'Redis® 软件' })).toHaveAttribute('href', '/zh/integrations/redis/'); await expect(page.locator('link[rel="canonical"]')) .toHaveAttribute('href', 'https://apisix.apache.org/zh/cookbooks/redis-shared-token-quota/'); diff --git a/website/cookbooks/en/redis-ai-cache.md b/website/cookbooks/en/redis-ai-cache.md index bd5a83b6bd8d1..0da6a20051594 100644 --- a/website/cookbooks/en/redis-ai-cache.md +++ b/website/cookbooks/en/redis-ai-cache.md @@ -81,6 +81,8 @@ The tested Route must include all of these boundaries: The runnable lab supplies the complete Route, Consumer authentication, provider configuration, private networks, and cleanup. It currently validates response headers and Redis® state; it does not enable or scrape the APISIX Prometheus plugin. The fragment above is the security contract, not a standalone deployment: `include_consumer` only works after APISIX authenticates a Consumer, and a production TLS connection must set `redis_ssl_verify: true`. +APISIX can forward client headers that are not part of the default AI cache key. The lab strips OpenAI organization, project, and beta-selection headers before proxying. In production, strip every client-controlled header that can change the provider response, or derive the value from trusted server-side state and add it to `cache_key.include_vars`. + ## Acceptance sequence | Check | Observable evidence | Failure signal | diff --git a/website/cookbooks/zh/redis-ai-cache.md b/website/cookbooks/zh/redis-ai-cache.md index bee5b1961e097..fc8b669daa97f 100644 --- a/website/cookbooks/zh/redis-ai-cache.md +++ b/website/cookbooks/zh/redis-ai-cache.md @@ -4,6 +4,7 @@ slug: redis-ai-cache translation_of: redis-ai-cache description: 构建并验证 APISIX 3.18 响应缓存链路,覆盖精确命中、语义匹配、租户隔离、完整流检查和 Redis® 故障测试。 difficulty: 中等 +duration: 45 分钟 --- 本 Cookbook 的发布门不只要求 Redis® 软件上的一次 `MISS` 和一次 `HIT`。它还要求证明:命中时真实模型没有被调用;语义匹配不超出文档支持的请求形态;不同租户不能复用彼此缓存;不完整的流永远不会写入缓存;Redis® 服务故障也不会让 LLM 链路不可用。 @@ -69,6 +70,8 @@ mock 或 fixture server 不能满足发布门。没有 Provider 凭据时,只 可运行实验会补全 Route、Consumer 认证、Provider 配置、私有网络和清理步骤。目前它通过响应 header 与 Redis® 状态验证,并未启用或抓取 APISIX Prometheus 插件。上面的片段是安全契约,并非独立部署配置:`include_consumer` 只有在 APISIX 已认证 Consumer 后才生效;生产 TLS 连接必须设置 `redis_ssl_verify: true`。 +APISIX 可能转发未进入默认 AI 缓存键的客户端 header。实验会在代理前移除 OpenAI organization、project 和 beta-selection header。生产环境应移除所有会改变 Provider 响应的客户端可控 header;或者从可信服务端状态生成该值,并通过 `cache_key.include_vars` 加入缓存键。 + ## 验收顺序 | 检查 | 可观察证据 | 失败信号 | diff --git a/website/cookbooks/zh/redis-shared-token-quota.md b/website/cookbooks/zh/redis-shared-token-quota.md index 997a8c7f5b46e..bd93635a70550 100644 --- a/website/cookbooks/zh/redis-shared-token-quota.md +++ b/website/cookbooks/zh/redis-shared-token-quota.md @@ -4,6 +4,7 @@ slug: redis-shared-token-quota translation_of: redis-shared-token-quota description: 验证两个 APISIX 3.18 节点共享同一个响应后 LLM token 计数,包括跨节点拒绝和明确的 Redis® 降级行为。 difficulty: 中等 +duration: 35 分钟 --- 本 Cookbook 定义如何验证两个 APISIX 节点使用同一个以 Redis® 软件为后端的 token 计数;在下述发布门通过前,不声称已完成端到端验证。同时明确记账边界:APISIX 在请求前检查已有计数,但只有在 LLM 返回后才加入真实 token usage。这是共享的上游用量配额,并非零超额的预算预留。 diff --git a/website/integrations/en/redis.md b/website/integrations/en/redis.md index 7f64324e9ba24..aad64de16f111 100644 --- a/website/integrations/en/redis.md +++ b/website/integrations/en/redis.md @@ -24,7 +24,7 @@ Apache APISIX 3.18 has two separate paths backed by Redis® software: `ai-cache` | Capability | APISIX 3.18 behavior | Important boundary | |---|---|---| -| Exact response cache | Redis® software stores the body of an HTTP 200 AI response keyed by a normalized effective request. The default TTL is 3,600 seconds and the default maximum response is 1 MiB. | The cache policy supports one Redis® endpoint in 3.18. It does not provide request coalescing, honor upstream `Cache-Control`, or expose a dedicated purge API. A hit reconstructs HTTP 200 and `Content-Type`; other upstream response headers are not stored or replayed. | +| Exact response cache | Redis® software stores the body of an HTTP 200 AI response keyed by a normalized request body and configured provider options. The default TTL is 3,600 seconds and the default maximum response is 1 MiB. | Arbitrary forwarded headers are not part of the default key. Strip client-controlled provider-routing headers, or represent every response-determining value with a trusted server-side variable in `cache_key.include_vars`. The cache policy supports one Redis® endpoint in 3.18. It does not provide request coalescing, honor upstream `Cache-Control`, or expose a dedicated purge API. A hit reconstructs HTTP 200 and `Content-Type`; other upstream response headers are not stored or replayed. | | Semantic response cache | After an exact miss, APISIX can embed a plain-text OpenAI Chat prompt and query Redis® Search for a similar cached response. | Semantic matching is limited to plain-text OpenAI Chat requests. Multimodal requests and non-empty tool or function calls bypass this layer. The exact layer remains enabled. | | Shared token quota | `ai-rate-limiting` can store a fixed-window token counter in a Redis® database so multiple APISIX nodes see the same usage. | Accounting happens after a model response supplies token usage. It is not a prepaid reservation: a large or concurrent response can cross the limit before a later request is rejected. | | Streaming cache | A complete supported SSE response can be cached after APISIX sees the protocol terminal event. | Interrupted streams are not cached. JSON and SSE use separate entries. A cache hit replays the complete stored SSE immediately; it does not reproduce the original token cadence. | @@ -33,7 +33,7 @@ Semantic caching requires Redis® Search commands. The companion lab pins Redis ## Isolation and security -Cache entries are isolated by Route by default, but Consumers on the same Route can share them. For multi-tenant traffic, first authenticate each tenant as a distinct Consumer and then set `cache_key.include_consumer: true`, or include a trusted server-side tenant variable. The option alone does not isolate unauthenticated traffic, and a client-controlled header is not a tenant boundary. +Cache entries are isolated by Route by default, but Consumers on the same Route can share them. For multi-tenant traffic, first authenticate each tenant as a distinct Consumer and then set `cache_key.include_consumer: true`, or include a trusted server-side tenant variable. The option alone does not isolate unauthenticated traffic, and a client-controlled header is not a tenant boundary. Strip any client-controlled header that can change provider routing or output before proxying; if a response-determining value must vary, derive it from trusted server-side state and include it with `cache_key.include_vars`. Redis® credentials should use an APISIX secret reference. Keep the Redis® endpoint on a private network, enable TLS certificate verification where TLS is used, and do not expose cached prompts, embeddings, provider request IDs, or full Redis® keys in logs or screenshots. diff --git a/website/integrations/zh/redis.md b/website/integrations/zh/redis.md index 78566a1901210..d859f7a327aba 100644 --- a/website/integrations/zh/redis.md +++ b/website/integrations/zh/redis.md @@ -16,7 +16,7 @@ Apache APISIX 3.18 提供两条彼此独立、以 Redis® 软件为后端的集 | 能力 | APISIX 3.18 行为 | 重要边界 | |---|---|---| -| 精确响应缓存 | Redis® 软件按规范化后的有效 AI 请求保存 HTTP 200 AI 响应的正文。默认 TTL 为 3,600 秒,默认最大响应为 1 MiB。 | 3.18 的缓存策略只支持一个 Redis® 地址;没有请求合并,不处理上游 `Cache-Control`,也没有专用清理 API。命中会重建 HTTP 200 与 `Content-Type`;其他上游响应 header 不会被保存或回放。 | +| 精确响应缓存 | Redis® 软件按规范化后的请求正文与 Provider 配置保存 HTTP 200 AI 响应的正文。默认 TTL 为 3,600 秒,默认最大响应为 1 MiB。 | 任意转发 header 默认不会进入缓存键。应移除客户端可控的 Provider 路由 header,或用可信服务端变量表示每个响应决定因素,并通过 `cache_key.include_vars` 加入缓存键。3.18 的缓存策略只支持一个 Redis® 地址;没有请求合并,不处理上游 `Cache-Control`,也没有专用清理 API。命中会重建 HTTP 200 与 `Content-Type`;其他上游响应 header 不会被保存或回放。 | | 语义响应缓存 | 精确缓存未命中后,APISIX 可为纯文本 OpenAI Chat prompt 生成 embedding,并通过 Redis® Search 查找相似响应。 | 语义匹配只适用于纯文本 OpenAI Chat。多模态请求和非空 tool/function call 会绕过这一层;精确缓存层始终启用。 | | 共享 token 配额 | `ai-rate-limiting` 可把固定窗口计数保存在 Redis® 数据库中,使多个 APISIX 节点看到同一份用量。 | 只有模型响应给出 usage 后才记账,并非预付式额度预留;大响应或并发响应可能先越过阈值,后续请求才被拒绝。 | | 流式缓存 | APISIX 识别到协议终止事件后,可缓存完整、受支持的 SSE 响应。 | 中断的流不会写入缓存;JSON 与 SSE 使用不同条目;命中时会立即回放完整 SSE,不会复现原始 token 节奏。 | @@ -25,7 +25,7 @@ Apache APISIX 3.18 提供两条彼此独立、以 Redis® 软件为后端的集 ## 隔离与安全 -缓存默认按 Route 隔离,但同一 Route 上的不同 Consumer 可能共享缓存。多租户流量必须先把各租户认证为不同 Consumer,再配置 `cache_key.include_consumer: true`;也可以加入可信的服务端租户变量。该选项本身不能隔离未认证流量,仅使用客户端可伪造的 header 也不能构成租户边界。 +缓存默认按 Route 隔离,但同一 Route 上的不同 Consumer 可能共享缓存。多租户流量必须先把各租户认证为不同 Consumer,再配置 `cache_key.include_consumer: true`;也可以加入可信的服务端租户变量。该选项本身不能隔离未认证流量,仅使用客户端可伪造的 header 也不能构成租户边界。代理前应移除任何会改变 Provider 路由或输出的客户端可控 header;如果响应决定因素确实需要变化,应从可信服务端状态生成,并通过 `cache_key.include_vars` 加入缓存键。 Redis® 凭据应使用 APISIX Secret 引用。Redis® 服务应位于私有网络;使用 TLS 时必须校验证书;日志和截图中不要暴露缓存正文、embedding、Provider request ID 或完整 Redis® key。 From 4efb1e7251a76e64f32effedfa56dd1ed5e280e7 Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Thu, 27 Aug 2026 10:44:23 +0800 Subject: [PATCH 5/8] fix(lab): use APISIX standalone YAML marker --- examples/redis-ai-gateway/conf/apisix.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/redis-ai-gateway/conf/apisix.yaml b/examples/redis-ai-gateway/conf/apisix.yaml index 1bbe328e794aa..616c31b027f42 100644 --- a/examples/redis-ai-gateway/conf/apisix.yaml +++ b/examples/redis-ai-gateway/conf/apisix.yaml @@ -205,4 +205,5 @@ routes: vector_search: redis: index: apisix-cookbook-semantic -# END +# eslint-disable-next-line yml/spaced-comment -- APISIX requires the exact marker below. +#END From 86ec95b1e286de1aa931ae8f82da510262256b1b Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Thu, 27 Aug 2026 10:53:14 +0800 Subject: [PATCH 6/8] docs(redis): align integration verification gate --- website/integrations/en/redis.md | 2 +- website/integrations/zh/redis.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/integrations/en/redis.md b/website/integrations/en/redis.md index aad64de16f111..8618ab35255bf 100644 --- a/website/integrations/en/redis.md +++ b/website/integrations/en/redis.md @@ -56,6 +56,6 @@ The APISIX Prometheus plugin exports cache hits by `exact` or `semantic` layer, - [Redis® Search module lifecycle](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/modules-lifecycle/) - [Pinned two-node APISIX and Redis® lab](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway) -This page remains **Validation in progress** until its pinned lab passes twice from a clean state and a second operator reproduces it. Source review establishes the intended 3.18.0 behavior; it is not runtime verification. +This page remains **Validation in progress** until the pinned lab records provider-side chat and embedding call counters, complete and interrupted SSE evidence, two clean runs, sanitized logs, and an independent second-operator reproduction. Source review establishes the intended 3.18.0 behavior; it is not runtime verification. Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. This community integration is not endorsed, supported, or certified by Redis®. diff --git a/website/integrations/zh/redis.md b/website/integrations/zh/redis.md index d859f7a327aba..6372795768f9d 100644 --- a/website/integrations/zh/redis.md +++ b/website/integrations/zh/redis.md @@ -48,6 +48,6 @@ APISIX Prometheus 插件会导出按 `exact` 或 `semantic` 分层的命中数 - [Redis® Search 模块生命周期](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/modules-lifecycle/) - [固定版本的双节点 APISIX 与 Redis® 实验](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway) -在固定版本实验从干净状态连续通过两次,并由第二位操作者复现前,本页面保持“**验证进行中**”。源码核对只能证明 3.18.0 的预期行为,不能替代运行时验证。 +在固定版本实验取得 Provider 侧 chat 与 embedding 调用计数、完整与中断 SSE 证据、两次干净运行、脱敏日志,并由第二位操作者独立复现前,本页面保持“**验证进行中**”。源码核对只能证明 3.18.0 的预期行为,不能替代运行时验证。 Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. 此社区集成未获得 Redis® 的认可、支持或认证。 From 1f89519bfdb72b046a51a7e41aa5c9d7cb239026 Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Thu, 27 Aug 2026 11:50:28 +0800 Subject: [PATCH 7/8] docs(ecosystem): refine Redis copy --- examples/redis-ai-gateway/README.md | 40 +++++----- .../src/components/EcosystemArticlePage.astro | 4 +- .../src/components/EcosystemCatalogPage.astro | 19 ++--- next/src/layouts/EcosystemDetail.astro | 14 ++-- next/src/lib/ecosystem.ts | 4 +- next/tests/e2e/ecosystem-pages.spec.mjs | 22 +++--- website/cookbooks/en/redis-ai-cache.md | 68 ++++++++--------- .../cookbooks/en/redis-shared-token-quota.md | 68 ++++++++--------- website/cookbooks/zh/redis-ai-cache.md | 76 +++++++++---------- .../cookbooks/zh/redis-shared-token-quota.md | 68 ++++++++--------- website/integrations/en/redis.md | 46 +++++------ website/integrations/zh/redis.md | 44 +++++------ website/static/llms.txt | 8 +- 13 files changed, 237 insertions(+), 244 deletions(-) diff --git a/examples/redis-ai-gateway/README.md b/examples/redis-ai-gateway/README.md index 31c6f4a7a8cbf..ebbc29e004c40 100644 --- a/examples/redis-ai-gateway/README.md +++ b/examples/redis-ai-gateway/README.md @@ -1,15 +1,15 @@ -# Apache APISIX 3.18 with Redis® software AI Gateway lab +# Apache APISIX 3.18 and Redis AI Gateway lab -This lab supports the Redis® Integration page and its two Cookbooks. It pins the gateway and store images, keeps the Redis® service and APISIX management surfaces off the host, and separates infrastructure checks from real-provider functional checks. +This lab accompanies the Redis integration page and its two cookbooks. It pins the APISIX and Redis images, keeps Redis and the APISIX management interfaces off the host, and separates infrastructure checks from tests that call a live provider. ## Pinned runtime - Apache APISIX 3.18.0, tag commit `0796d9c2cbedb1f8bf8194292ff526599f4fde20` - `apache/apisix:3.18.0-debian@sha256:84e6b5e787e9f889ebff88161cb9a16599bafcffa236c6b54c7f779a0655940d` -- Redis® Open Source 8.10.1 with its bundled Search module explicitly loaded +- Redis Open Source 8.10.1 with its bundled Search module explicitly loaded - `redis:8-alpine@sha256:becdda6c7f4b3fb42e42fd7f120bbf5c54c4caaaf16f26da24e4563d2c1f0576` -The image references are multi-architecture registry digests, but this Search-enabled lab supports only `linux/amd64` and `linux/arm64` because the official Redis® Open Source 8.10.1 image builds bundled modules only for those architectures. Record the platform-specific image ID in every published result. +The images are pinned with multi-architecture registry digests. This lab runs only on `linux/amd64` and `linux/arm64` because the bundled modules in the official Redis Open Source 8.10.1 image are available only for those architectures. When sharing results, include the platform-specific image ID. ## Run the infrastructure preflight @@ -19,22 +19,22 @@ Requirements: Docker Compose, Bash, `awk`, `cmp`, `curl`, `jq`, and OpenSSL. ./scripts/setup.sh ``` -The setup script creates a mode-`0600` `.env` with a random isolated Compose instance ID and random Redis® and Consumer secrets, recreates two APISIX nodes and one private ephemeral Redis® service from a clean state, and checks: +The setup script creates a mode-`0600` `.env` with a unique Compose project ID and generated Redis and Consumer secrets. It starts two APISIX nodes and a private, ephemeral Redis instance from a clean state, then checks: - both APISIX Status APIs report ready; - only gateway ports `127.0.0.1:9080` and `127.0.0.1:9081` are published; - the missing-key and valid-key authentication paths behave as expected without calling a provider; -- the Redis® service is not published to the host; -- the Redis® service reports version 8.10.1 and accepts `FT._LIST`; +- the Redis service is not published to the host; +- the Redis service reports version 8.10.1 and accepts `FT._LIST`; - Compose resolved the expected immutable image digests. -This preflight does **not** call an LLM and is not cache or quota E2E evidence. +This preflight does **not** call an LLM, so it does not verify caching or quota behavior end to end. -## Run real-provider checks +## Run the OpenAI tests Add a dedicated, least-privilege OpenAI API key to `.env`. The tests use `gpt-4o-mini` and `text-embedding-3-small` by default. They never print the key or prompt/response bodies. -The Routes remove successful key-auth credentials and client-supplied OpenAI organization, project, and beta-selection headers before proxying. This keeps forwarded headers that can change the provider response outside the lab's cache boundary. The lab access-log format excludes raw query strings. Send the Consumer key in the `apikey` header; do not place credentials in URLs. +Before proxying, the Routes remove the Consumer credential and any client-supplied OpenAI organization, project, or beta header. These headers can affect the provider response but are not part of the cache key. Access logs also omit raw query strings. Send the Consumer key in the `apikey` header; never put credentials in the URL. ```dotenv OPENAI_API_KEY=replace-me @@ -48,24 +48,24 @@ Then run: ./scripts/test-failure-modes.sh ``` -The quota test sends one real request through node A, waits until post-response token usage is committed to the Redis® database, requires the counter to equal provider `usage.total_tokens`, then requires node B to return the configured `429` from the same counter. +The quota test sends a live request through node A and waits for APISIX to write the provider's `usage.total_tokens` value to Redis. It then checks that node B applies the same counter and returns the configured `429`. -The cache test verifies an exact cross-node hit, byte-identical response-body replay, Consumer isolation, one semantic paraphrase hit, semantic-to-exact backfill, and an unrelated miss. It never lowers the similarity threshold automatically. +The cache test verifies an exact cross-node hit, byte-identical response-body replay, Consumer isolation, a semantic hit for a paraphrased prompt, semantic-to-exact backfill, and an unrelated miss. It never lowers the similarity threshold automatically. -The failure test distinguishes cache fail-open behavior from rate-limit behavior with `allow_degradation` disabled or explicitly enabled. +The failure test compares cache fail-open behavior with rate limiting when `allow_degradation` is `false` and when it is `true`. -## What is still outside this lab +## Remaining validation -Passing these scripts is not enough to mark the public Cookbooks E2E verified. Publication additionally requires: +The public pages will remain **Validation in progress** until the following checks are also complete: - provider-side chat and embedding call counters, correlated to each request; - complete and interrupted SSE cases; -- two clean runs and an independent second-operator reproduction; +- two clean runs and a rerun by another operator; - sanitized APISIX logs proving no credential or body leakage; -- separate real failover profiles before claiming Redis® Cluster or Sentinel support; +- live Redis Cluster and Sentinel failover tests before either mode is marked verified; - a documented test date, machine architecture, provider region, and model identifiers. -APISIX token accounting happens after the provider response and can overshoot under a large or concurrent response. It is not prepaid budget reservation. Cache storage supports one Redis® endpoint in APISIX 3.18.0; this lab does not claim cache HA. +APISIX records token usage after the provider responds, so a large response or concurrent requests can exceed the limit. This is not a prepaid budget. In APISIX 3.18.0, `ai-cache` uses one Redis endpoint; this lab does not test cache HA. ## Cleanup @@ -73,6 +73,6 @@ APISIX token accounting happens after the provider response and can overshoot un ./scripts/cleanup.sh ``` -Cleanup removes only the randomly named lab instance's containers and network. The lab does not publish or persist Redis® data, and its scripts never run `FLUSHALL` against an external service. +Cleanup removes only the containers and network for the lab's unique Compose project. Redis has no host port and its data is not persisted. The scripts never run `FLUSHALL` against an external service. Cleanup leaves the mode-`0600`, Git-ignored `.env` in place so you can run the lab again. Delete that file when you are finished, especially if it contains a provider key. -Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. This community lab is not endorsed, supported, or certified by Redis®. +Redis is a registered trademark of Redis Ltd. This community lab is not endorsed, supported, or certified by Redis Ltd. diff --git a/next/src/components/EcosystemArticlePage.astro b/next/src/components/EcosystemArticlePage.astro index 9dbf1af43af7e..09affb58cf8b2 100644 --- a/next/src/components/EcosystemArticlePage.astro +++ b/next/src/components/EcosystemArticlePage.astro @@ -65,8 +65,8 @@ const linkedIntegrations = resource.kind === 'cookbook' )} {linkedIntegrations.length > 0 && ( -
-

{t(locale, 'Used integrations', '使用的集成')}

+
+
@@ -151,7 +150,9 @@ const collectionSchema = { )} - {t(locale, 'Open guide', '打开指南')} + {entry.kind === 'integration' + ? t(locale, 'View integration', '查看集成') + : t(locale, 'View cookbook', '查看指南')} ))} diff --git a/next/src/layouts/EcosystemDetail.astro b/next/src/layouts/EcosystemDetail.astro index f4cdea13ad2ec..29f05a4bc52cf 100644 --- a/next/src/layouts/EcosystemDetail.astro +++ b/next/src/layouts/EcosystemDetail.astro @@ -119,19 +119,19 @@ const breadcrumbSchema = { -
+
APISIX
{apisixVersion}
{externalVersion &&
{t(locale, 'Dependency', '依赖')}
{externalVersion}
} {protocols.length > 0 &&
{t(locale, 'Protocols', '协议')}
{protocols.join(', ')}
} {difficulty &&
{t(locale, 'Difficulty', '难度')}
{difficulty}
} {duration &&
{t(locale, 'Time', '时间')}
{duration}
}
-
{t(locale, 'Last verified', '最后验证')}
+
{t(locale, 'Last verified', '最近验证')}
{lastVerified && verificationUrl ? {lastVerified} - : t(locale, 'Pending', '待完成')}
+ : t(locale, 'Pending', '尚未验证')}
-
{t(locale, 'Source review', '源码核对')}
{reviewedAt}
+
{t(locale, 'Code reviewed', '源码审查')}
{reviewedAt}
@@ -139,11 +139,11 @@ const breadcrumbSchema = {
{verification === 'validation-in-progress' && ( )} diff --git a/next/src/lib/ecosystem.ts b/next/src/lib/ecosystem.ts index 8c5f9a6afe423..b88dbca142149 100644 --- a/next/src/lib/ecosystem.ts +++ b/next/src/lib/ecosystem.ts @@ -55,12 +55,12 @@ export const verificationLabels: { [key: VerificationStatus]: LocalizedText } = }; export const integrationCategoryLabels: { [key: IntegrationCategory]: LocalizedText } = { - data: { en: 'Data, cache, and rate-limit backends', zh: '数据、缓存与限流后端' }, + data: { en: 'Data stores, caching, and rate limiting', zh: '数据存储、缓存与限流' }, }; export const cookbookCategoryLabels: { [key: CookbookCategory]: LocalizedText } = { cost: { en: 'Cost and quotas', zh: '成本与配额' }, - reliability: { en: 'Reliability and resilience', zh: '可靠性与韧性' }, + reliability: { en: 'Reliability', zh: '可靠性' }, }; export function localize(locale: Locale, value: LocalizedText | string): string { diff --git a/next/tests/e2e/ecosystem-pages.spec.mjs b/next/tests/e2e/ecosystem-pages.spec.mjs index 511056de305e3..3fb6ce5f05fae 100644 --- a/next/tests/e2e/ecosystem-pages.spec.mjs +++ b/next/tests/e2e/ecosystem-pages.spec.mjs @@ -51,13 +51,13 @@ test('header navigation reaches an Integration and its Cookbook', async ({ page await expect(page).toHaveURL(/\/integrations\/$/); await page.locator('[data-resource="redis"]').click(); - await expect(page.getByRole('heading', { level: 1, name: 'Redis® software' })).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: 'Redis' })).toBeVisible(); await page.getByRole('link', { - name: 'Cache LLM responses using Redis® software for exact and semantic matching', + name: 'Cache LLM responses with Redis: exact and semantic matching', }).click(); await expect(page.getByRole('heading', { level: 1, - name: 'Cache LLM responses using Redis® software for exact and semantic matching', + name: 'Cache LLM responses with Redis: exact and semantic matching', })).toBeVisible(); }); @@ -73,20 +73,20 @@ test('catalogs derive their cards and metadata from Markdown', async ({ page }) await expectNoPageOverflow(page); await page.goto('/zh/cookbooks/'); - await expect(page.getByRole('heading', { level: 1, name: '运行一个完整场景,而不只是复制配置' })).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: 'Apache APISIX 实践指南' })).toBeVisible(); await expect(page.getByTestId('ecosystem-card')).toHaveCount(2); - await expect(page.locator('[data-resource="redis-ai-cache"]')).toContainText('使用 Redis® 软件的精确与语义匹配缓存 LLM 响应'); + await expect(page.locator('[data-resource="redis-ai-cache"]')).toContainText('使用 Redis 缓存 LLM 响应:精确匹配与语义匹配'); await expectNoPageOverflow(page); }); test('Redis detail pages expose translations, relationships, and source-review boundaries', async ({ page }) => { test.slow(); await page.goto('/integrations/redis/'); - await expect(page.getByRole('heading', { level: 1, name: 'Redis® software' })).toBeVisible(); - await expect(page.getByText('Publication gate:')).toBeVisible(); - await expect(page.getByRole('link', { name: 'Cache LLM responses using Redis® software for exact and semantic matching' })) + await expect(page.getByRole('heading', { level: 1, name: 'Redis' })).toBeVisible(); + await expect(page.getByText('Verification note:')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Cache LLM responses with Redis: exact and semantic matching' })) .toHaveAttribute('href', '/cookbooks/redis-ai-cache/'); - await expect(page.getByRole('link', { name: 'Share an LLM token quota across APISIX nodes with Redis® software' })) + await expect(page.getByRole('link', { name: 'Share an LLM token quota across APISIX nodes with Redis' })) .toHaveAttribute('href', '/cookbooks/redis-shared-token-quota/'); await expect(page.locator('link[rel="canonical"]')) .toHaveAttribute('href', 'https://apisix.apache.org/integrations/redis/'); @@ -99,11 +99,11 @@ test('Redis detail pages expose translations, relationships, and source-review b await expectNoPageOverflow(page); await page.goto('/zh/cookbooks/redis-shared-token-quota/'); - await expect(page.getByRole('heading', { level: 1, name: '使用 Redis® 软件在多个 APISIX 节点间共享 LLM token 配额' })).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: '使用 Redis 在多个 APISIX 节点间共享 LLM token 配额' })).toBeVisible(); await expect(page.locator('.breadcrumbs').getByRole('link', { name: 'Cookbook', exact: true })) .toHaveAttribute('href', '/zh/cookbooks/'); await expect(page.locator('.resource-facts')).toContainText('35 分钟'); - await expect(page.getByRole('link', { name: 'Redis® 软件' })).toHaveAttribute('href', '/zh/integrations/redis/'); + await expect(page.getByRole('link', { name: 'Redis' })).toHaveAttribute('href', '/zh/integrations/redis/'); await expect(page.locator('link[rel="canonical"]')) .toHaveAttribute('href', 'https://apisix.apache.org/zh/cookbooks/redis-shared-token-quota/'); await expectNoPageOverflow(page); diff --git a/website/cookbooks/en/redis-ai-cache.md b/website/cookbooks/en/redis-ai-cache.md index 0da6a20051594..3a43ba163c0d4 100644 --- a/website/cookbooks/en/redis-ai-cache.md +++ b/website/cookbooks/en/redis-ai-cache.md @@ -1,14 +1,14 @@ --- -title: Cache LLM responses using Redis® software for exact and semantic matching +title: "Cache LLM responses with Redis: exact and semantic matching" slug: redis-ai-cache -description: Build and validate an APISIX 3.18 response-cache path with exact hits, semantic matches, tenant isolation, complete-stream checks, and Redis® failure tests. +description: Test exact and semantic response caching in APISIX 3.18, including tenant isolation, streaming responses, and Redis failures. category: cost verification: validation-in-progress owner: Apache APISIX community difficulty: Intermediate duration: 45 minutes apisix_version: 3.18.0 -external_version: Redis® Open Source 8.10.1 +external_version: Redis Open Source 8.10.1 integrations: - redis plugins: @@ -18,37 +18,37 @@ reviewed_at: "2026-08-25" evidence_url: https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache.lua --- -This cookbook sets a publication gate stricter than a `MISS` followed by a `HIT` with Redis® software. The gate requires proof that a real model was not called on a hit, semantic matching stays within its documented request shape, tenants cannot reuse each other's entries, incomplete streams are never cached, and a Redis® service outage does not make the LLM path unavailable. +A `MISS` followed by a `HIT` is not enough to validate an AI cache. This cookbook checks that cache hits avoid model calls, semantic matching stays within the supported request format, tenants remain isolated, incomplete streams are not cached, and Redis failures fall back to the LLM. -## Outcome +## What you'll test -From a clean Redis® database, the lab will prove these behaviors against the real OpenAI Chat and Embeddings APIs: +Starting with an empty Redis database, run the lab against the live OpenAI Chat and Embeddings APIs and check the following: 1. The first identical request is a miss and calls the chat provider once. 2. The second identical request is an exact hit and does not call the chat provider. -3. A calibrated paraphrase is a semantic hit; an unrelated prompt is a miss. -4. When the best-effort L1 backfill succeeds, repeating the paraphrase is an exact hit and does not call the embedding provider again. A backfill error is logged while the semantic hit is still served. +3. A paraphrased prompt produces a semantic hit; an unrelated prompt produces a miss. +4. After a semantic hit is copied into the exact cache, repeating the paraphrase produces an exact hit without another embedding call. If that copy fails, APISIX still serves the semantic hit and logs the error. 5. Consumer B cannot hit an entry warmed by Consumer A. 6. A complete supported SSE response can be reused, while an interrupted response cannot. -7. When the Redis® service or the embedding endpoint is unavailable, APISIX continues to the real chat provider as a miss. +7. When the Redis service or the embedding endpoint is unavailable, APISIX continues to the live chat provider as a miss. -## Pinned scope +## Version and test requirements -The gateway code is pinned to the [APISIX 3.18.0 tag commit](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20). Before this page changes to **E2E verified**, the lab must also record: +The gateway code is pinned to the [APISIX 3.18.0 tag commit](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20). Keep this page at **Validation in progress** until the lab records: -- the immutable APISIX and Redis® image digests; -- the Redis® server version and successful `FT.CREATE`/`FT.SEARCH` smoke test; +- the immutable APISIX and Redis image digests; +- the Redis server version and successful `FT.CREATE`/`FT.SEARCH` smoke test; - chat and embedding provider names, model identifiers, region, and test time; -- sanitized provider call counters independent of APISIX response headers; -- two clean runs plus a second-operator reproduction. +- provider-side call counts that do not rely on APISIX response headers; +- two runs from an empty environment and an independent retest by another operator. -No mock or fixture server can satisfy this gate. If provider credentials are unavailable, only the container and Redis® Search preflight may run; the cache result remains unverified. +A mock or fixture server cannot replace these checks. Without provider credentials, you can run the container and Redis Search preflight, but the cache result remains unverified. -[Open the pinned lab](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway). Its `check-infra.sh` can run without provider credentials; `test-cache.sh` requires the real OpenAI Chat and Embeddings APIs. +[Open the pinned lab](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway). Its `check-infra.sh` can run without provider credentials; `test-cache.sh` requires the live OpenAI Chat and Embeddings APIs. -## Safe configuration shape +## Configuration -The tested Route must include all of these boundaries: +Start with the following `ai-cache` settings: ```json { @@ -79,24 +79,24 @@ The tested Route must include all of these boundaries: } ``` -The runnable lab supplies the complete Route, Consumer authentication, provider configuration, private networks, and cleanup. It currently validates response headers and Redis® state; it does not enable or scrape the APISIX Prometheus plugin. The fragment above is the security contract, not a standalone deployment: `include_consumer` only works after APISIX authenticates a Consumer, and a production TLS connection must set `redis_ssl_verify: true`. +This snippet shows the cache settings only. The linked lab also configures the Route, Consumer authentication, provider credentials, private networks, and cleanup. It checks response headers and Redis state, but does not enable or scrape the APISIX Prometheus plugin. `include_consumer` works only after APISIX authenticates a Consumer. For production TLS connections, set `redis_ssl_verify: true`. APISIX can forward client headers that are not part of the default AI cache key. The lab strips OpenAI organization, project, and beta-selection headers before proxying. In production, strip every client-controlled header that can change the provider response, or derive the value from trusted server-side state and add it to `cache_key.include_vars`. -## Acceptance sequence +## Test checklist -| Check | Observable evidence | Failure signal | +| Check | Expected result | What is not enough | |---|---|---| | Exact cache | `MISS` then `HIT`, byte-identical response body, chat-provider counter delta `+1` then `+0` | Inferring provider calls from the cache header alone | | Semantic cache | Different prompt returns `HIT` with a similarity header at or above the configured threshold; unrelated prompt returns `MISS` | Hard-coding a similarity score that is not reproduced by the pinned embedding model | -| L2 to L1 backfill | Backfill succeeds; the repeated paraphrase is an exact `HIT` with embedding counter delta `+0` | A backfill warning, or another embedding call on the repeat | +| Semantic hit copied to exact cache | The repeated paraphrase is an exact `HIT` with embedding counter delta `+0` | A copy warning, or another embedding call on the repeat | | Tenant isolation | Consumer B gets `MISS`; Consumer A still gets `HIT` | Trusting a caller-supplied tenant header without server-side validation | | Streaming | Complete SSE is reusable; a deliberately interrupted SSE remains `MISS` on retry | Calling a partial stream cacheable or claiming token-paced replay | -| Failure behavior | A Redis® outage returns a real model response with `MISS`; logs identify the backend failure without request bodies or secrets | Treating `MISS` as proof that the Redis® service is healthy | +| Failure behavior | A Redis outage returns a live model response with `MISS`; logs identify the backend failure without request bodies or secrets | Treating `MISS` as proof that the Redis service is healthy | ## Measure the result -Use provider access counters for chat and embedding calls. In a separate deployment where the APISIX Prometheus plugin is enabled and scraped, use these series for cache behavior: +Use the provider's own logs or counters to measure chat and embedding calls. If your test deployment also enables and scrapes the APISIX Prometheus plugin, use these metrics to inspect cache behavior: - `apisix_ai_cache_hits_total{layer="exact"}` - `apisix_ai_cache_hits_total{layer="semantic"}` @@ -104,19 +104,17 @@ Use provider access counters for chat and embedding calls. In a separate deploym - `apisix_ai_cache_bypasses_total` - `apisix_ai_cache_embedding_latency_bucket` -Calculate hit ratio as `hits / (hits + misses)` and report bypass coverage separately. Do not translate cache headers or historical response `usage` fields directly into cost savings. A savings claim requires the same fixed request set with cache disabled and enabled, real provider usage, workload repetition rate, and disclosed sample size. +Calculate hit ratio as `hits / (hits + misses)` and report bypass coverage separately. Do not estimate savings from cache headers or cached `usage` fields alone. Compare the same fixed workload with caching enabled and disabled, using provider-reported usage, the workload's repetition rate, and a disclosed sample size. -## Production boundaries +## Before using this in production -- APISIX 3.18 cache storage uses a single Redis® endpoint; this cookbook does not claim Redis® Cluster or Sentinel support for `ai-cache`. -- Semantic matching applies only to plain-text OpenAI Chat requests. Tool calls, multimodal inputs, and other protocols do not become semantically cacheable by configuration. -- A cache hit is not rescanned by a lower-priority guardrail plugin. Do not combine cache and newly changed safety policy without an explicit invalidation plan. -- Never publish full prompts, cached responses, embeddings, API keys, provider request IDs, or full Redis® keys as evidence. +- APISIX 3.18 cache storage uses a single Redis endpoint; this cookbook does not claim Redis Cluster or Sentinel support for `ai-cache`. +- Semantic matching applies only to plain-text OpenAI Chat requests. Tool calls, multimodal inputs, and other protocols are not supported by semantic caching. +- A cache hit bypasses any lower-priority guardrail plugin. When a guardrail policy changes, invalidate responses cached under the previous policy before serving them again. +- Never publish full prompts, cached responses, embeddings, API keys, provider request IDs, or full Redis keys. ## Cleanup -The lab cleanup must delete the APISIX Routes and Consumers, remove only the uniquely prefixed cache index and keys, stop the isolated containers, and confirm no secret-bearing environment file is tracked. It must not flush a shared Redis® database. +Delete the lab Routes and Consumers, remove only the cache index and keys created by this lab, and stop the isolated containers. Confirm that no file containing credentials is tracked. Never flush a shared Redis database. -The command assets and captured results will be linked here after the real-service acceptance gate passes. - -Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. This community cookbook is not endorsed, supported, or certified by Redis®. +Redis is a registered trademark of Redis Ltd. This community cookbook is not endorsed, supported, or certified by Redis Ltd. diff --git a/website/cookbooks/en/redis-shared-token-quota.md b/website/cookbooks/en/redis-shared-token-quota.md index 0399822b5b39f..378b875066bbf 100644 --- a/website/cookbooks/en/redis-shared-token-quota.md +++ b/website/cookbooks/en/redis-shared-token-quota.md @@ -1,14 +1,14 @@ --- -title: Share an LLM token quota across APISIX nodes with Redis® software +title: Share an LLM token quota across APISIX nodes with Redis slug: redis-shared-token-quota -description: Validate one post-response LLM token counter across two APISIX 3.18 nodes, including cross-node rejection and explicit Redis® degradation behavior. +description: Share a post-response token counter between two APISIX 3.18 nodes, and test cross-node enforcement and Redis failure handling. category: reliability verification: validation-in-progress owner: Apache APISIX community difficulty: Intermediate duration: 35 minutes apisix_version: 3.18.0 -external_version: Redis® Open Source 8.10.1 +external_version: Redis Open Source 8.10.1 integrations: - redis plugins: @@ -18,30 +18,30 @@ reviewed_at: "2026-08-25" evidence_url: https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-rate-limiting.lua --- -This cookbook defines how to verify that two APISIX nodes use one token counter backed by Redis® software; it does not claim E2E verification until the publication gate below passes. It also makes the accounting boundary explicit: APISIX checks the existing counter before a request, but adds the real token usage only after the LLM response. This is a shared upstream-usage quota, not a zero-overshoot budget reservation. +This cookbook shows how two APISIX nodes can share one Redis-backed LLM token counter. APISIX reads the current counter before proxying a request and records the provider-reported usage after the response. A request can therefore take usage past the limit before the next request is rejected. Treat this as a shared usage limit, not a prepaid budget. -## Outcome +## What you'll test -The real-service lab must prove: +Run the lab against a live provider and check the following: -1. A request sent to APISIX node A succeeds and its provider-reported token usage is written to the Redis® database. +1. A request sent to APISIX node A succeeds and its provider-reported token usage is written to the Redis database. 2. A request sent to node B sees the same remaining quota. 3. The response that crosses the quota may still succeed; the next request on either node receives the configured `429`. -4. A provider response without a usable `usage` object does not increase the counter and is reported as an accounting gap. -5. With `allow_degradation: false`, a pre-request Redis® check failure returns an error rather than silently bypassing quota. -6. With `allow_degradation: true`, the request reaches the real provider and is explicitly classified as unprotected traffic. +4. A provider response without a usable `usage` object does not increase the counter; record the request as missing usage data. +5. With `allow_degradation: false`, a pre-request Redis check failure returns an error rather than silently bypassing quota. +6. With `allow_degradation: true`, the request reaches the live provider; record it as bypassing quota enforcement. -## Pinned scope +## Version and test requirements -The gateway is fixed to the [APISIX 3.18.0 tag commit](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20). The first verified profile uses the single-node `redis` policy. Redis® Cluster and Redis® Sentinel appear in the plugin schema, but each requires a separate real topology and failover run before this cookbook can claim those profiles as verified. +The gateway is fixed to the [APISIX 3.18.0 tag commit](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20). This test covers the `redis` policy with one Redis endpoint. The plugin schema also supports Redis Cluster and Redis Sentinel, but each needs its own topology and failover test before it can be marked as verified. -The lab must pin immutable APISIX and Redis® image digests, use a real LLM that returns token usage, start from an empty uniquely scoped counter, pass twice, and be reproduced by a second operator. A mock response with a fabricated `usage` field is not E2E evidence. +Use the pinned APISIX and Redis image digests, a live LLM that reports token usage, and an empty counter unique to the test. Run the test twice from a clean state and ask another operator to reproduce it. A mock response with a fabricated `usage` field does not verify the full flow. [Open the pinned two-node lab](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway). Its infrastructure preflight needs no provider key; `test-shared-quota.sh` requires a real OpenAI response with token usage. -## Configuration contract +## Configuration -Use an explicit rejection code and keep the failure policy visible: +Set the rejection code and degradation behavior explicitly: ```json { @@ -60,39 +60,37 @@ Use an explicit rejection code and keep the failure policy visible: } ``` -The complete lab adds the `ai-proxy` provider and credentials through secret references, gives both APISIX nodes the same Route and Redis® configuration, and exposes only their gateway ports. Admin, Control API, and Redis® ports stay on private networks. +The linked lab adds the `ai-proxy` provider and credentials through secret references. Both APISIX nodes use the same Route and Redis configuration, and only their gateway ports are exposed. The Admin API, Control API, and Redis ports remain on private networks. -## Acceptance sequence +## Test checklist -| Step | Node | Expected evidence | +| Step | Node | Expected result | |---|---|---| -| Baseline | A | HTTP 200, real provider response, quota headers, provider `usage`, and one corresponding Redis® counter increase | +| Baseline | A | HTTP 200, live provider response, quota headers, provider `usage`, and one corresponding Redis counter increase | | Cross-node read | B | Remaining quota reflects node A's usage rather than a fresh local budget | -| Cross the limit | A or B | The crossing response may be HTTP 200; the committed Redis® value exceeds the configured limit | +| Cross the limit | A or B | The crossing response may be HTTP 200; the committed Redis value exceeds the configured limit | | Enforce | Other node | The next request is HTTP 429 and provider call count does not increase | -| Reset | Both | Only after the fixed window expires do both nodes accept traffic under a new counter window | -| Pre-request fail closed | A | The Redis® service is unavailable before the quota check, so degradation disabled returns an error and provider count stays unchanged | -| Degrade open | B | The Redis® service is unavailable before the quota check, so degradation enabled reaches the provider; evidence labels it as not quota-protected | +| Reset | Both | After the fixed window expires, both nodes accept traffic under a new counter window | +| Pre-request fail closed | A | The Redis service is unavailable before the quota check, so degradation disabled returns an error and provider count stays unchanged | +| Degrade open | B | The Redis service is unavailable before the quota check, so degradation enabled reaches the provider; record the request as bypassing quota enforcement | -Poll the Redis® counter after every successful model response before issuing the next assertion. Otherwise, response-log timing can make a valid post-response write look missing. +After each successful model response, wait for the Redis counter to update before running the next check. Token accounting happens in the log phase, so checking immediately can produce a false failure. -## What the headers do not prove +## Verify the counter, not just the headers -Quota headers are useful client feedback, but they are not independent accounting evidence. Capture the response headers, provider `usage`, Redis® value and TTL, APISIX error log, and provider call delta for the same request. Do not expose the complete Redis® key if it contains Consumer, Route, or model identifiers. +Quota headers tell the client how much quota remains, but they do not prove that usage was recorded. For the same request, capture the response headers, provider `usage`, Redis value and TTL, APISIX error log, and provider call count. Do not publish a complete Redis key if it contains Consumer, Route, or model identifiers. -## Production boundaries +## Before using this in production - This fixed-window counter does not reserve the prompt's worst-case completion tokens before sending the request. Concurrent requests can overshoot. -- When the provider does not return token usage, the response cannot be charged by this mechanism. -- `allow_degradation: true` preserves availability by removing quota protection during a Redis® fault. Alert on that state. -- `allow_degradation: false` only fails closed for a Redis® error observed by the pre-request check. A failure in the asynchronous post-response write cannot retract the response and can leave usage uncommitted; alert on APISIX write errors and reconcile against provider usage. +- When the provider does not return token usage, the usage cannot be added to the counter. +- `allow_degradation: true` lets the request continue without quota enforcement during a Redis fault. Alert on that state. +- `allow_degradation: false` only fails closed for a Redis error observed by the pre-request check. A failure in the asynchronous post-response write cannot retract the response and can leave usage uncommitted; alert on APISIX write errors and reconcile against provider usage. - `ai-cache` hits return before the rate-limiter runs and do not consume this upstream token quota. -- For a per-tenant quota, authenticate the caller and configure `rules.key` from a trusted server-side identity such as `$consumer_name`. Authentication alone does not partition the default constant counter. Do not base a paid quota on a caller-controlled header. +- For a per-tenant quota, authenticate the caller and configure `rules.key` from a trusted server-side identity such as `$consumer_name`. Authentication alone does not create a separate counter for each tenant. Do not base a paid quota on a caller-controlled header. ## Cleanup -Delete the lab Routes, remove only the uniquely scoped Redis® counter keys, stop both APISIX nodes and the Redis® service, and verify no credential file is tracked. Do not use `FLUSHALL` against a shared Redis® service. +Delete the lab Routes, remove only the Redis counter keys created by the lab, and stop both APISIX nodes and Redis. Confirm that no file containing credentials is tracked. Never run `FLUSHALL` against a shared Redis service. -The command assets and sanitized evidence will be linked here after the real-service acceptance gate passes. - -Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. This community cookbook is not endorsed, supported, or certified by Redis®. +Redis is a registered trademark of Redis Ltd. This community cookbook is not endorsed, supported, or certified by Redis Ltd. diff --git a/website/cookbooks/zh/redis-ai-cache.md b/website/cookbooks/zh/redis-ai-cache.md index fc8b669daa97f..c504f75995460 100644 --- a/website/cookbooks/zh/redis-ai-cache.md +++ b/website/cookbooks/zh/redis-ai-cache.md @@ -1,43 +1,43 @@ --- -title: 使用 Redis® 软件的精确与语义匹配缓存 LLM 响应 +title: 使用 Redis 缓存 LLM 响应:精确匹配与语义匹配 slug: redis-ai-cache translation_of: redis-ai-cache -description: 构建并验证 APISIX 3.18 响应缓存链路,覆盖精确命中、语义匹配、租户隔离、完整流检查和 Redis® 故障测试。 +description: 验证 APISIX 3.18 的精确与语义缓存,包括租户隔离、流式响应和 Redis 故障处理。 difficulty: 中等 duration: 45 分钟 --- -本 Cookbook 的发布门不只要求 Redis® 软件上的一次 `MISS` 和一次 `HIT`。它还要求证明:命中时真实模型没有被调用;语义匹配不超出文档支持的请求形态;不同租户不能复用彼此缓存;不完整的流永远不会写入缓存;Redis® 服务故障也不会让 LLM 链路不可用。 +一次 `MISS` 和一次 `HIT` 还不足以说明 AI 缓存可用。本指南还会检查:命中时是否跳过真实模型调用、语义匹配是否只处理支持的请求格式、租户缓存是否隔离、中断的流是否不会写入缓存,以及 Redis 故障时请求是否仍能回退到 LLM。 -## 预期结果 +## 实验内容 -从空 Redis® 数据库开始,实验将使用真实 OpenAI Chat 与 Embeddings API 验证: +从空 Redis 数据库开始,使用真实 OpenAI Chat 与 Embeddings API 检查以下行为: -1. 第一个相同请求未命中,并调用一次 chat Provider。 -2. 第二个相同请求精确命中,不再调用 chat Provider。 -3. 经过校准的改写问题语义命中,无关问题未命中。 -4. best-effort L1 回填成功后,再次发送改写问题会精确命中,且不再调用 embedding Provider。回填出错时会记录日志,但当前语义命中仍会返回。 +1. 第一个相同请求未命中,并调用一次 Chat Provider。 +2. 第二个相同请求精确命中,不再调用 Chat Provider。 +3. 与原问题语义相近的改写应命中,无关问题应未命中。 +4. 语义命中成功写入精确缓存后,再次发送改写后的问题应精确命中,且不再调用 Embedding Provider。写入失败时记录日志,但不影响当前的语义命中响应。 5. Consumer B 不能命中 Consumer A 预热的缓存。 6. 完整、受支持的 SSE 可复用,中断响应不能复用。 -7. Redis® 服务或 embedding endpoint 不可用时,APISIX 仍以缓存未命中方式调用真实 chat Provider。 +7. Redis 服务或 Embedding 端点不可用时,APISIX 仍以缓存未命中方式调用 Chat Provider。 -## 固定范围 +## 版本与测试要求 -网关源码固定到 [APISIX 3.18.0 标签提交](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20)。页面变为“**端到端已验证**”前,实验还必须记录: +网关源码固定到 [APISIX 3.18.0 标签提交](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20)。完成以下记录前,页面保持“**验证进行中**”: -- APISIX 与 Redis® 不可变镜像 digest; -- Redis® 服务端版本,以及成功的 `FT.CREATE`/`FT.SEARCH` 预检; -- chat 与 embedding Provider、模型标识、区域和测试时间; -- 独立于 APISIX 响应 header 的脱敏 Provider 调用计数; -- 两次干净运行与第二位操作者复现。 +- APISIX 与 Redis 的不可变镜像摘要(digest); +- Redis 服务端版本,以及成功的 `FT.CREATE`/`FT.SEARCH` 预检; +- Chat 与 Embedding Provider、模型标识、区域和测试时间; +- 不依赖 APISIX 响应头的 Provider 侧调用次数; +- 从空环境重复运行两次,并由另一位操作者独立复测。 -mock 或 fixture server 不能满足发布门。没有 Provider 凭据时,只能验证容器与 Redis® Search 预检,不能把缓存结果标记为已验证。 +Mock 或 fixture server 不能替代这些检查。没有 Provider 凭据时,可以运行容器与 Redis Search 预检,但缓存结果仍不能标记为已验证。 [打开固定版本实验](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway)。`check-infra.sh` 无需 Provider 凭据即可运行;`test-cache.sh` 需要真实 OpenAI Chat 与 Embeddings API。 -## 安全配置形态 +## 配置示例 -被测试的 Route 必须包含以下边界: +测试 Route 至少应包含以下设置: ```json { @@ -68,24 +68,24 @@ mock 或 fixture server 不能满足发布门。没有 Provider 凭据时,只 } ``` -可运行实验会补全 Route、Consumer 认证、Provider 配置、私有网络和清理步骤。目前它通过响应 header 与 Redis® 状态验证,并未启用或抓取 APISIX Prometheus 插件。上面的片段是安全契约,并非独立部署配置:`include_consumer` 只有在 APISIX 已认证 Consumer 后才生效;生产 TLS 连接必须设置 `redis_ssl_verify: true`。 +上面的片段只展示缓存设置。配套实验还会配置 Route、Consumer 认证、Provider 凭据、私有网络和清理步骤。实验会检查响应 header 与 Redis 状态,但不会启用或抓取 APISIX Prometheus 插件。`include_consumer` 只有在 APISIX 已认证 Consumer 后才生效;生产环境使用 TLS 时,必须设置 `redis_ssl_verify: true`。 -APISIX 可能转发未进入默认 AI 缓存键的客户端 header。实验会在代理前移除 OpenAI organization、project 和 beta-selection header。生产环境应移除所有会改变 Provider 响应的客户端可控 header;或者从可信服务端状态生成该值,并通过 `cache_key.include_vars` 加入缓存键。 +APISIX 可能会转发未计入默认缓存键的客户端请求头。实验会在代理前删除 OpenAI 的 organization、project 和 beta-selection 请求头。生产环境中,凡是会影响 Provider 响应的客户端可控请求头,都应在代理前删除;如确需保留,应由服务端生成可信值,并通过 `cache_key.include_vars` 纳入缓存键。 -## 验收顺序 +## 测试清单 -| 检查 | 可观察证据 | 失败信号 | +| 检查 | 通过条件 | 不能作为依据 | |---|---|---| -| 精确缓存 | `MISS` 后 `HIT`,缓存响应正文字节一致,chat Provider 计数依次为 `+1`、`+0` | 仅根据缓存 header 推测 Provider 调用数 | -| 语义缓存 | 不同问题返回带 similarity header 的 `HIT`,数值不低于阈值;无关问题返回 `MISS` | 固定写死无法由当前 embedding 模型复现的分数 | -| L2 回填 L1 | 回填成功;再次发送改写问题是精确 `HIT`,embedding 计数增量为 `+0` | 出现回填告警,或重复请求再次调用 embedding 服务 | -| 租户隔离 | Consumer B 得到 `MISS`,Consumer A 仍为 `HIT` | 只依赖调用方可伪造的租户 header | -| 流式响应 | 完整 SSE 可复用;主动中断的 SSE 重试仍为 `MISS` | 把 partial stream 称为可缓存,或宣称命中后仍按 token 节奏回放 | -| 故障行为 | Redis® 服务停止后仍由真实模型返回响应并显示 `MISS`;日志能识别后端故障但不含正文和密钥 | 把 `MISS` 当作 Redis® 服务健康证明 | +| 精确缓存 | `MISS` 后 `HIT`,缓存响应正文字节一致,Chat Provider 计数依次为 `+1`、`+0` | 仅根据缓存响应头推测 Provider 调用数 | +| 语义缓存 | 不同问题返回带 similarity 响应头的 `HIT`,数值不低于阈值;无关问题返回 `MISS` | 固定写死无法由当前 Embedding 模型复现的分数 | +| 语义命中写入精确缓存 | 再次发送改写问题是精确 `HIT`,Embedding 计数增量为 `+0` | 出现写入告警,或重复请求再次调用 Embedding 服务 | +| 租户隔离 | Consumer B 得到 `MISS`,Consumer A 仍为 `HIT` | 只依赖调用方可伪造的租户请求头 | +| 流式响应 | 完整 SSE 可复用;主动中断的 SSE 重试仍为 `MISS` | 把中断的流称为可缓存,或宣称命中后仍按 Token 节奏回放 | +| 故障行为 | Redis 服务停止后仍由真实模型返回响应并显示 `MISS`;日志能识别后端故障但不含正文和密钥 | 把 `MISS` 当作 Redis 服务健康证明 | ## 结果度量 -chat 和 embedding 调用数必须来自 Provider 侧计数。在另行启用并抓取 APISIX Prometheus 插件的部署中,可使用以下 series 观察缓存行为: +Chat 和 Embedding 的调用次数应以 Provider 侧数据为准。如果测试环境另行启用并抓取了 APISIX Prometheus 插件,可通过以下指标观察缓存行为: - `apisix_ai_cache_hits_total{layer="exact"}` - `apisix_ai_cache_hits_total{layer="semantic"}` @@ -93,19 +93,17 @@ chat 和 embedding 调用数必须来自 Provider 侧计数。在另行启用并 - `apisix_ai_cache_bypasses_total` - `apisix_ai_cache_embedding_latency_bucket` -命中率按 `hits / (hits + misses)` 计算,绕过覆盖率单独报告。不要直接把缓存 header 或历史响应中的 `usage` 转换为成本节省。成本结论必须使用同一固定请求集分别关闭和启用缓存,并披露真实 Provider usage、工作负载重复率和样本数。 +命中率按 `hits / (hits + misses)` 计算,绕过请求比例另行统计。不要仅凭缓存响应头或历史响应中的 `usage` 估算节省成本。评估成本时,应对同一组请求分别关闭和启用缓存,并记录 Provider 实际用量、请求重复率和样本量。 -## 生产边界 +## 生产环境注意事项 -- APISIX 3.18 的缓存后端只支持一个 Redis® endpoint;本文不声称 `ai-cache` 支持 Redis® Cluster 或 Sentinel。 +- APISIX 3.18 的缓存后端只支持一个 Redis 端点;本文不声称 `ai-cache` 支持 Redis Cluster 或 Sentinel。 - 语义匹配仅适用于纯文本 OpenAI Chat。tool call、多模态输入和其他协议不会因配置而自动支持语义缓存。 - 缓存命中不会再经过优先级更低的 Guardrail 插件。安全策略变化后若没有明确的失效方案,不要直接组合使用。 -- 证据中不得公开完整 prompt、缓存响应、embedding、API key、Provider request ID 或完整 Redis® key。 +- 不要公开完整 Prompt、缓存响应、Embedding、API key、Provider request ID 或完整 Redis key。 ## 清理 -实验清理必须删除 APISIX Route 和 Consumer,只删除带唯一前缀的缓存索引与 key,停止隔离容器,并确认含密钥的环境文件没有被 Git 跟踪。不得清空共享 Redis® 数据库。 +删除实验 Route 和 Consumer,只清理本实验创建的缓存索引与 key,然后停止隔离容器。确认含凭据的文件没有被 Git 跟踪。不要清空共享 Redis 数据库。 -真实服务验收通过后,本页会补充命令资产和脱敏结果链接。 - -Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. 此社区 Cookbook 未获得 Redis® 的认可、支持或认证。 +Redis 是 Redis Ltd. 的注册商标。本社区指南与 Redis Ltd. 无隶属关系,也未获得其认可、支持或认证。 diff --git a/website/cookbooks/zh/redis-shared-token-quota.md b/website/cookbooks/zh/redis-shared-token-quota.md index bd93635a70550..b25a286a63324 100644 --- a/website/cookbooks/zh/redis-shared-token-quota.md +++ b/website/cookbooks/zh/redis-shared-token-quota.md @@ -1,36 +1,36 @@ --- -title: 使用 Redis® 软件在多个 APISIX 节点间共享 LLM token 配额 +title: 使用 Redis 在多个 APISIX 节点间共享 LLM token 配额 slug: redis-shared-token-quota translation_of: redis-shared-token-quota -description: 验证两个 APISIX 3.18 节点共享同一个响应后 LLM token 计数,包括跨节点拒绝和明确的 Redis® 降级行为。 +description: 验证两个 APISIX 3.18 节点能否通过 Redis 共享 LLM Token 用量,并正确执行跨节点限流与故障降级。 difficulty: 中等 duration: 35 分钟 --- -本 Cookbook 定义如何验证两个 APISIX 节点使用同一个以 Redis® 软件为后端的 token 计数;在下述发布门通过前,不声称已完成端到端验证。同时明确记账边界:APISIX 在请求前检查已有计数,但只有在 LLM 返回后才加入真实 token usage。这是共享的上游用量配额,并非零超额的预算预留。 +两个 APISIX 节点可以通过 Redis 共用一份 LLM Token 计数。APISIX 会在代理请求前读取当前计数,但只在模型返回后记录 Provider 上报的实际用量。因此,某个请求可能先让用量超过上限,后续请求才会被拒绝。它适合限制共享用量,不能当作预付预算。 -## 预期结果 +## 实验内容 -真实服务实验必须证明: +使用真实 Provider 运行实验,并检查以下行为: -1. 发往 APISIX 节点 A 的请求成功,Provider 返回的 token usage 被写入 Redis® 数据库。 -2. 发往节点 B 的请求看到同一份剩余额度。 -3. 使额度越界的响应仍可能成功;之后发往任一节点的请求才返回配置的 `429`。 +1. 发往 APISIX 节点 A 的请求成功,Provider 返回的 Token 用量被写入 Redis 数据库。 +2. 节点 B 能读取节点 A 已消耗的用量和对应的剩余额度。 +3. 导致用量超限的请求本身仍可能成功;后续请求才会返回配置的 `429`。 4. Provider 响应没有可用 `usage` 时,计数不会增加,并明确记录为记账缺口。 -5. `allow_degradation: false` 时,请求前 Redis® 检查失败会返回错误,而不是静默绕过配额。 -6. `allow_degradation: true` 时,请求会到达真实 Provider,并被明确标记为没有配额保护的流量。 +5. `allow_degradation: false` 时,请求前 Redis 检查失败会返回错误,而不是静默绕过配额。 +6. `allow_degradation: true` 时,请求会到达 Provider;测试结果应注明该请求绕过了配额限制。 -## 固定范围 +## 版本与测试要求 -网关固定到 [APISIX 3.18.0 标签提交](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20)。首个验证 profile 使用单节点 `redis` policy。插件 Schema 还包含 Redis® Cluster 与 Redis® Sentinel,但只有各自在真实拓扑和故障切换实验通过后,本文才能把它们标记为已验证。 +网关固定到 [APISIX 3.18.0 标签提交](https://github.com/apache/apisix/commit/0796d9c2cbedb1f8bf8194292ff526599f4fde20)。本指南只测试使用单个 Redis 端点的 `redis` policy。插件 Schema 也支持 Redis Cluster 和 Redis Sentinel,但需要分别完成真实拓扑与故障切换测试,才能标记为已验证。 -实验必须固定 APISIX 与 Redis® 的不可变镜像 digest,使用会返回 token usage 的真实 LLM,从空的唯一作用域计数开始,连续通过两次,并由第二位操作者复现。带伪造 `usage` 的 mock 响应不是端到端证据。 +实验环境使用指定的 APISIX 与 Redis 镜像摘要(digest)、会返回 Token 用量的 LLM,以及仅供本次测试使用的空计数器。从空环境重复运行两次,并由另一位操作者独立复测。带伪造 `usage` 的 Mock 响应不能验证完整链路。 [打开固定版本的双节点实验](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway)。基础设施预检无需 Provider key;`test-shared-quota.sh` 需要真实 OpenAI 响应中的 token usage。 -## 配置契约 +## 配置示例 -显式设置拒绝状态码,并让故障策略保持可见: +请显式设置拒绝状态码和降级策略: ```json { @@ -49,39 +49,37 @@ duration: 35 分钟 } ``` -完整实验会通过 Secret 引用补充 `ai-proxy` Provider 与凭据,为两个 APISIX 节点下发相同 Route 和 Redis® 配置,并只暴露 Gateway 端口。Admin、Control API 与 Redis® 端口都保留在私有网络。 +配套实验通过 Secret 引用配置 `ai-proxy` Provider 与凭据。两个 APISIX 节点使用相同的 Route 和 Redis 配置,只对外暴露 Gateway 端口;Admin API、Control API 和 Redis 端口都保留在私有网络。 -## 验收顺序 +## 测试清单 -| 步骤 | 节点 | 预期证据 | +| 步骤 | 节点 | 预期结果 | |---|---|---| -| 基线 | A | HTTP 200、真实 Provider 响应、配额 header、Provider `usage`,以及一次对应的 Redis® 计数增加 | +| 基线 | A | HTTP 200、Provider 响应、配额响应头、Provider `usage`,以及一次对应的 Redis 计数增加 | | 跨节点读取 | B | 剩余额度包含节点 A 的用量,而不是一份新的本地预算 | -| 越过阈值 | A 或 B | 越界响应可能仍为 HTTP 200;Redis® 数据库中已提交的数值超过配置阈值 | +| 越过阈值 | A 或 B | 越界响应可能仍为 HTTP 200;Redis 数据库中已提交的数值超过配置阈值 | | 执行拒绝 | 另一个节点 | 下一个请求返回 HTTP 429,Provider 调用数不增加 | | 窗口重置 | 两个节点 | 固定窗口过期后,两边才会在新窗口内重新接受流量 | -| 请求前故障关闭 | A | Redis® 服务在配额检查前不可用,禁用降级时返回错误,Provider 调用数不增加 | -| 故障放行 | B | Redis® 服务在配额检查前不可用,启用降级时到达 Provider;证据明确标注此请求不受配额保护 | +| 请求前故障关闭 | A | Redis 服务在配额检查前不可用,禁用降级时返回错误,Provider 调用数不增加 | +| 故障放行 | B | Redis 服务在配额检查前不可用,启用降级时到达 Provider;测试结果注明该请求绕过了配额限制 | -每次模型成功响应后,都要轮询 Redis® 计数再执行下一条断言。否则,响应日志与记账时序可能让合法的响应后写入看起来像缺失。 +每次模型响应成功后,先轮询 Redis,确认计数已写入,再进行下一项检查。记账发生在 log 阶段,若立即读取,可能误判为漏记。 -## Header 不能单独证明什么 +## 不要只看响应头 -配额 header 适合给客户端反馈,但不是独立的记账证据。应为同一请求同时保存响应 header、Provider `usage`、Redis® value 与 TTL、APISIX error log 和 Provider 调用增量。若完整 Redis® key 包含 Consumer、Route 或模型标识,不得公开。 +配额响应头只能告诉客户端剩余额度,不能单独证明用量已经记账。对同一个请求,应同时保存响应头、Provider `usage`、Redis 中的值与 TTL、APISIX error log 和 Provider 调用次数变化。若完整 Redis key 包含 Consumer、Route 或模型标识,不要公开。 -## 生产边界 +## 生产环境注意事项 -- 固定窗口计数不会在请求上游前预留 prompt 的最大 completion token;并发请求可能越界。 -- Provider 不返回 token usage 时,此机制无法给响应记账。 -- `allow_degradation: true` 通过在 Redis® 故障时移除配额保护来保持可用性,必须对这一状态告警。 -- `allow_degradation: false` 只会对请求前检查观测到的 Redis® 错误执行故障关闭。异步响应后写入失败无法撤回当前响应,并可能使 usage 未提交;必须对 APISIX 写入错误告警,并与 Provider usage 对账。 +- 固定窗口计数不会在调用上游前按请求可能生成的最大 Token 数预留额度;并发请求可能超限。 +- Provider 不返回 Token 用量时,此机制无法给响应记账。 +- Redis 故障时,`allow_degradation: true` 会放行请求,但此时不再提供配额保护;应对此状态告警。 +- `allow_degradation: false` 只在请求前 Redis 检查失败时拒绝请求。响应后的异步写入失败无法撤回已返回的响应,也可能导致用量未记录。应监控 APISIX 写入错误,并与 Provider 用量对账。 - `ai-cache` 命中会在限流插件之前返回,不消耗这份上游 token 配额。 -- 每租户配额必须先认证调用方,并用可信的服务端身份(例如 `$consumer_name`)配置 `rules.key`。仅启用认证不会拆分默认的 constant 计数;付费配额不得使用调用方可伪造的 header 作为 key。 +- 每租户配额必须先认证调用方,并用可信的服务端身份(例如 `$consumer_name`)配置 `rules.key`。仅启用认证不会为每个租户创建独立计数;付费配额不得使用调用方可伪造的请求头作为键。 ## 清理 -删除实验 Route,只删除带唯一作用域的 Redis® counter key,停止两个 APISIX 节点与 Redis® 服务,并确认没有凭据文件被 Git 跟踪。不得对共享 Redis® 服务执行 `FLUSHALL`。 +删除实验 Route,只清理本实验创建的 Redis counter key,然后停止两个 APISIX 节点与 Redis。确认含凭据的文件没有被 Git 跟踪。不要对共享 Redis 服务执行 `FLUSHALL`。 -真实服务验收通过后,本页会补充命令资产和脱敏证据链接。 - -Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. 此社区 Cookbook 未获得 Redis® 的认可、支持或认证。 +Redis 是 Redis Ltd. 的注册商标。本社区指南与 Redis Ltd. 无隶属关系,也未获得其认可、支持或认证。 diff --git a/website/integrations/en/redis.md b/website/integrations/en/redis.md index 8618ab35255bf..3f57f2ff77ad1 100644 --- a/website/integrations/en/redis.md +++ b/website/integrations/en/redis.md @@ -1,61 +1,61 @@ --- -title: Redis® software +title: Redis slug: redis -description: Use Redis® software as the exact and semantic response-cache backend for APISIX AI Gateway, or as the shared token-counter backend across APISIX nodes. +description: Use Redis for exact and semantic response caching in APISIX AI Gateway, or to share token counters across APISIX nodes. category: data method: Built-in APISIX plugins verification: validation-in-progress owner: Apache APISIX community apisix_version: 3.18.0 -external_version: Redis® Open Source 8.10.1 +external_version: Redis Open Source 8.10.1 protocols: - RESP reviewed_at: "2026-08-25" evidence_url: https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache.lua --- -Apache APISIX 3.18 has two separate paths backed by Redis® software: `ai-cache` stores reusable LLM responses, while `ai-rate-limiting` shares post-response token counters across APISIX nodes. They have different topology and failure behavior, so choose and operate them independently. +Apache APISIX 3.18 uses Redis in two different ways. `ai-cache` stores reusable LLM responses, while `ai-rate-limiting` stores token counters shared by multiple APISIX nodes. These features have different deployment and failure characteristics, so configure and operate them separately. -
- AI clientApache APISIXRedis® software +
+ AI clientApache APISIXRedis
-## Capability scope +## How APISIX uses Redis -| Capability | APISIX 3.18 behavior | Important boundary | +| Use case | APISIX 3.18 behavior | Limits and operational notes | |---|---|---| -| Exact response cache | Redis® software stores the body of an HTTP 200 AI response keyed by a normalized request body and configured provider options. The default TTL is 3,600 seconds and the default maximum response is 1 MiB. | Arbitrary forwarded headers are not part of the default key. Strip client-controlled provider-routing headers, or represent every response-determining value with a trusted server-side variable in `cache_key.include_vars`. The cache policy supports one Redis® endpoint in 3.18. It does not provide request coalescing, honor upstream `Cache-Control`, or expose a dedicated purge API. A hit reconstructs HTTP 200 and `Content-Type`; other upstream response headers are not stored or replayed. | -| Semantic response cache | After an exact miss, APISIX can embed a plain-text OpenAI Chat prompt and query Redis® Search for a similar cached response. | Semantic matching is limited to plain-text OpenAI Chat requests. Multimodal requests and non-empty tool or function calls bypass this layer. The exact layer remains enabled. | -| Shared token quota | `ai-rate-limiting` can store a fixed-window token counter in a Redis® database so multiple APISIX nodes see the same usage. | Accounting happens after a model response supplies token usage. It is not a prepaid reservation: a large or concurrent response can cross the limit before a later request is rejected. | +| Exact response cache | Redis stores the body of an HTTP 200 AI response. The key includes a normalized request body and configured provider options. The default TTL is 3,600 seconds and the default maximum response is 1 MiB. | Forwarded headers are not part of the default key. If a header can change the provider response, remove it or map it to a trusted server-side variable and include that variable in `cache_key.include_vars`. APISIX 3.18 supports one Redis endpoint for the cache. It does not coalesce concurrent cache misses, honor upstream `Cache-Control`, or provide a purge API. A hit restores HTTP 200 and `Content-Type`; other upstream response headers are not replayed. | +| Semantic response cache | After an exact miss, APISIX can embed a plain-text OpenAI Chat prompt and query Redis Search for a similar cached response. | Semantic matching is limited to plain-text OpenAI Chat requests. Multimodal requests and non-empty tool or function calls bypass this layer. The exact layer remains enabled. | +| Shared token quota | `ai-rate-limiting` can store a fixed-window token counter in a Redis database so multiple APISIX nodes see the same usage. | Accounting happens after a model response supplies token usage. It is not a prepaid reservation: a large or concurrent response can cross the limit before a later request is rejected. | | Streaming cache | A complete supported SSE response can be cached after APISIX sees the protocol terminal event. | Interrupted streams are not cached. JSON and SSE use separate entries. A cache hit replays the complete stored SSE immediately; it does not reproduce the original token cadence. | -Semantic caching requires Redis® Search commands. The companion lab pins Redis® Open Source 8.10.1, and its infrastructure preflight checks that `FT._LIST` is available. That check is infrastructure evidence only; it does not verify the LLM cache path. For an earlier Redis® Open Source or Redis® Stack release, pin and test the exact version rather than assuming compatibility. +Semantic caching requires Redis Search commands. The companion lab pins Redis Open Source 8.10.1 and checks that `FT._LIST` is available. This preflight confirms the Redis setup, not the LLM cache path. If you use an earlier Redis Open Source or Redis Stack release, pin and test that exact version. ## Isolation and security -Cache entries are isolated by Route by default, but Consumers on the same Route can share them. For multi-tenant traffic, first authenticate each tenant as a distinct Consumer and then set `cache_key.include_consumer: true`, or include a trusted server-side tenant variable. The option alone does not isolate unauthenticated traffic, and a client-controlled header is not a tenant boundary. Strip any client-controlled header that can change provider routing or output before proxying; if a response-determining value must vary, derive it from trusted server-side state and include it with `cache_key.include_vars`. +By default, APISIX separates cache entries by Route, not by Consumer. To isolate tenants, authenticate each tenant as a distinct Consumer and set `cache_key.include_consumer: true`, or add a trusted server-side tenant variable to the key. This option does not isolate unauthenticated traffic, and a client-controlled header is not a tenant boundary. Remove any client-controlled header that can change provider routing or output. If a response must vary by another value, derive it from trusted server-side state and include it with `cache_key.include_vars`. -Redis® credentials should use an APISIX secret reference. Keep the Redis® endpoint on a private network, enable TLS certificate verification where TLS is used, and do not expose cached prompts, embeddings, provider request IDs, or full Redis® keys in logs or screenshots. +Store Redis credentials in an APISIX Secret and keep Redis on a private network. Enable certificate verification when using TLS, and do not expose cached prompts, embeddings, provider request IDs, or full Redis keys in logs or screenshots. ## Failure behavior -- Cache, vector-search, and embedding errors degrade to a cache miss, so APISIX continues to the LLM. A `MISS` header alone does not prove the Redis® service is healthy. -- Shared quota is different. A pre-request Redis® quota check failure returns an error when `allow_degradation: false`, or lets the request continue without quota protection when it is `true`. This setting cannot fail closed after the LLM response: if the Redis® service fails between the access check and the asynchronous log-phase counter write, the response can succeed and the token increment can remain uncommitted; alert on write errors. -- A cache hit returns before `ai-rate-limiting` runs. It avoids an upstream model call and does not increase the Redis® token counter. +- Cache, vector-search, and embedding errors degrade to a cache miss, so APISIX continues to the LLM. A `MISS` header alone does not prove the Redis service is healthy. +- Shared quota behaves differently. A pre-request Redis check returns an error when `allow_degradation: false`; when set to `true`, APISIX lets the request continue without quota protection. This setting applies only to the pre-request check. If Redis fails before the log-phase write completes, the response may succeed without recording its token usage. Monitor APISIX for write errors. +- A cache hit returns before `ai-rate-limiting` runs. It avoids an upstream model call and does not increase the Redis token counter. ## Observability -The APISIX Prometheus plugin exports cache hits by `exact` or `semantic` layer, misses, bypasses, and embedding-latency histograms. `ai-rate-limiting` does not export a dedicated Redis® token-counter metric; validate it with response headers, Redis® state, APISIX logs, and provider usage together. +The APISIX Prometheus plugin exports cache hits by `exact` or `semantic` layer, misses, bypasses, and embedding-latency histograms. `ai-rate-limiting` does not export a dedicated Redis token-counter metric. To verify shared token usage, compare the response headers, Redis state, APISIX logs, and provider-reported usage. -## Source-reviewed references +## References - [`ai-cache` source at the APISIX 3.18.0 tag](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache.lua) - [`ai-cache` schema](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/schema.lua) - [Semantic-cache implementation](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/semantic.lua) - [`ai-rate-limiting` source](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-rate-limiting.lua) -- [Redis® Search module lifecycle](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/modules-lifecycle/) -- [Pinned two-node APISIX and Redis® lab](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway) +- [Redis Search module lifecycle](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/modules-lifecycle/) +- [Pinned two-node APISIX and Redis lab](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway) -This page remains **Validation in progress** until the pinned lab records provider-side chat and embedding call counters, complete and interrupted SSE evidence, two clean runs, sanitized logs, and an independent second-operator reproduction. Source review establishes the intended 3.18.0 behavior; it is not runtime verification. +The behavior above matches the APISIX 3.18.0 source, but runtime testing is not complete. Keep this page marked **Validation in progress** until the lab records provider-side chat and embedding call counts, complete and interrupted SSE results, two clean runs, sanitized logs, and a rerun by another operator. -Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. This community integration is not endorsed, supported, or certified by Redis®. +Redis is a registered trademark of Redis Ltd. This community integration is not endorsed, supported, or certified by Redis Ltd. diff --git a/website/integrations/zh/redis.md b/website/integrations/zh/redis.md index 6372795768f9d..eeb8ed6fe8104 100644 --- a/website/integrations/zh/redis.md +++ b/website/integrations/zh/redis.md @@ -1,53 +1,53 @@ --- -title: Redis® 软件 +title: Redis slug: redis translation_of: redis -description: 使用 Redis® 软件作为 APISIX AI Gateway 的精确与语义响应缓存后端,或在多个 APISIX 节点间共享 token 计数。 +description: 在 APISIX AI Gateway 中使用 Redis 缓存 LLM 响应,或在多个 APISIX 节点间共享 token 计数。 method: APISIX 内置插件 --- -Apache APISIX 3.18 提供两条彼此独立、以 Redis® 软件为后端的集成路径:`ai-cache` 保存可复用的 LLM 响应,`ai-rate-limiting` 则在多个 APISIX 节点之间共享响应后的 token 计数。两者的拓扑与失败行为不同,应分别选择和运维。 +Apache APISIX 3.18 以两种不同方式使用 Redis:`ai-cache` 保存可复用的 LLM 响应,`ai-rate-limiting` 保存多个 APISIX 节点共享的 token 计数。两者的部署方式和故障处理不同,需要分别配置和运维。 -
- AI 客户端Apache APISIXRedis® 软件 +
+ AI 客户端Apache APISIXRedis
-## 能力范围 +## APISIX 如何使用 Redis -| 能力 | APISIX 3.18 行为 | 重要边界 | +| 场景 | APISIX 3.18 行为 | 限制与运维说明 | |---|---|---| -| 精确响应缓存 | Redis® 软件按规范化后的请求正文与 Provider 配置保存 HTTP 200 AI 响应的正文。默认 TTL 为 3,600 秒,默认最大响应为 1 MiB。 | 任意转发 header 默认不会进入缓存键。应移除客户端可控的 Provider 路由 header,或用可信服务端变量表示每个响应决定因素,并通过 `cache_key.include_vars` 加入缓存键。3.18 的缓存策略只支持一个 Redis® 地址;没有请求合并,不处理上游 `Cache-Control`,也没有专用清理 API。命中会重建 HTTP 200 与 `Content-Type`;其他上游响应 header 不会被保存或回放。 | -| 语义响应缓存 | 精确缓存未命中后,APISIX 可为纯文本 OpenAI Chat prompt 生成 embedding,并通过 Redis® Search 查找相似响应。 | 语义匹配只适用于纯文本 OpenAI Chat。多模态请求和非空 tool/function call 会绕过这一层;精确缓存层始终启用。 | -| 共享 token 配额 | `ai-rate-limiting` 可把固定窗口计数保存在 Redis® 数据库中,使多个 APISIX 节点看到同一份用量。 | 只有模型响应给出 usage 后才记账,并非预付式额度预留;大响应或并发响应可能先越过阈值,后续请求才被拒绝。 | +| 精确响应缓存 | Redis 保存 HTTP 200 AI 响应的正文。缓存键包含规范化后的请求正文和 Provider 配置。默认 TTL 为 3,600 秒,默认最大响应为 1 MiB。 | 默认缓存键不包含转发 header。应移除客户端可控的 Provider 路由 header;如果其他值也会影响响应,应从可信服务端变量取值,并通过 `cache_key.include_vars` 加入缓存键。APISIX 3.18 的缓存只支持一个 Redis endpoint,不提供请求合并,也不处理上游 `Cache-Control`,没有专用清理 API。命中时会恢复 HTTP 200 与 `Content-Type`,其他上游响应 header 不会回放。 | +| 语义响应缓存 | 精确缓存未命中后,APISIX 可为纯文本 OpenAI Chat prompt 生成 embedding,并通过 Redis Search 查找相似响应。 | 语义匹配只适用于纯文本 OpenAI Chat。多模态请求和非空 tool/function call 会绕过这一层;精确缓存层始终启用。 | +| 共享 token 配额 | `ai-rate-limiting` 可把固定窗口计数保存在 Redis 数据库中,使多个 APISIX 节点看到同一份用量。 | 只有模型响应给出 usage 后才记账,并非预付式额度预留;大响应或并发响应可能先越过阈值,后续请求才被拒绝。 | | 流式缓存 | APISIX 识别到协议终止事件后,可缓存完整、受支持的 SSE 响应。 | 中断的流不会写入缓存;JSON 与 SSE 使用不同条目;命中时会立即回放完整 SSE,不会复现原始 token 节奏。 | -语义缓存需要 Redis® Search 命令。配套实验固定 Redis® Open Source 8.10.1,其基础设施预检会检查 `FT._LIST` 是否可用。该检查仅属于基础设施证据,不能验证 LLM 缓存链路。若使用更早的 Redis® Open Source 或 Redis® Stack 版本,应固定并验证精确版本,不要默认兼容。 +语义缓存需要 Redis Search 命令。配套实验固定使用 Redis Open Source 8.10.1,并检查 `FT._LIST` 是否可用。这个预检只能确认 Redis 环境正常,不能验证 LLM 缓存链路。若使用更早的 Redis Open Source 或 Redis Stack 版本,需要固定并测试具体版本。 ## 隔离与安全 -缓存默认按 Route 隔离,但同一 Route 上的不同 Consumer 可能共享缓存。多租户流量必须先把各租户认证为不同 Consumer,再配置 `cache_key.include_consumer: true`;也可以加入可信的服务端租户变量。该选项本身不能隔离未认证流量,仅使用客户端可伪造的 header 也不能构成租户边界。代理前应移除任何会改变 Provider 路由或输出的客户端可控 header;如果响应决定因素确实需要变化,应从可信服务端状态生成,并通过 `cache_key.include_vars` 加入缓存键。 +缓存默认按 Route 隔离,而不是按 Consumer 隔离。如果多个 Consumer 共用一条 Route,应先把每个租户认证为独立 Consumer,再设置 `cache_key.include_consumer: true`;也可以把可信的服务端租户变量加入缓存键。这个选项不能隔离未认证流量,客户端可伪造的 header 也不能作为租户边界。代理前应移除会改变 Provider 路由或输出的客户端可控 header。其他影响响应的值应来自可信的服务端状态,并通过 `cache_key.include_vars` 加入缓存键。 -Redis® 凭据应使用 APISIX Secret 引用。Redis® 服务应位于私有网络;使用 TLS 时必须校验证书;日志和截图中不要暴露缓存正文、embedding、Provider request ID 或完整 Redis® key。 +Redis 凭据应使用 APISIX Secret 引用。Redis 服务应位于私有网络;使用 TLS 时必须校验证书;日志和截图中不要暴露缓存正文、embedding、Provider request ID 或完整 Redis key。 ## 失败行为 -- 缓存、向量搜索或 embedding 出错时会降级为缓存未命中,APISIX 继续请求 LLM。因此,仅看到 `MISS` header 不能证明 Redis® 服务健康。 -- 共享配额的行为不同:请求前 Redis® 配额检查失败时,`allow_degradation: false` 返回错误,设为 `true` 则在无配额保护的情况下继续。该设置无法对响应后记账失败执行关闭:若 Redis® 服务在 access 检查与异步 log 阶段计数写入之间故障,当前响应仍可能成功且 token 增量可能未提交;必须对写入错误告警。 -- 缓存命中会在 `ai-rate-limiting` 之前直接返回,因此不会调用上游模型,也不会增加 Redis® token 计数。 +- 缓存、向量搜索或 embedding 出错时会降级为缓存未命中,APISIX 继续请求 LLM。因此,仅看到 `MISS` header 不能证明 Redis 服务健康。 +- 共享配额的处理方式不同。请求前 Redis 配额检查失败时,`allow_degradation: false` 会返回错误;设为 `true` 时会放行请求,但不再提供配额保护。这个开关只影响请求前检查。如果 Redis 在 access 阶段检查完成后、log 阶段异步写入计数前发生故障,当前响应仍可能成功,Token 用量也可能未写入;应监控写入错误。 +- 缓存命中会在 `ai-rate-limiting` 之前直接返回,因此不会调用上游模型,也不会增加 Redis token 计数。 ## 可观测性 -APISIX Prometheus 插件会导出按 `exact` 或 `semantic` 分层的命中数、未命中数、绕过数和 embedding 延迟直方图。`ai-rate-limiting` 没有专用的 Redis® token-counter 指标,应结合响应 header、Redis® 状态、APISIX 日志和 Provider usage 进行验证。 +APISIX Prometheus 插件会导出按 `exact` 或 `semantic` 分层的命中数、未命中数、绕过数和 embedding 延迟直方图。`ai-rate-limiting` 没有专用的 Redis token-counter 指标,应结合响应 header、Redis 状态、APISIX 日志和 Provider usage 进行验证。 -## 已核对的源码 +## 参考资料 - [APISIX 3.18.0 标签中的 `ai-cache` 源码](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache.lua) - [`ai-cache` Schema](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/schema.lua) - [语义缓存实现](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/semantic.lua) - [`ai-rate-limiting` 源码](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-rate-limiting.lua) -- [Redis® Search 模块生命周期](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/modules-lifecycle/) -- [固定版本的双节点 APISIX 与 Redis® 实验](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway) +- [Redis Search 模块生命周期](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/modules-lifecycle/) +- [固定版本的双节点 APISIX 与 Redis 实验](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway) -在固定版本实验取得 Provider 侧 chat 与 embedding 调用计数、完整与中断 SSE 证据、两次干净运行、脱敏日志,并由第二位操作者独立复现前,本页面保持“**验证进行中**”。源码核对只能证明 3.18.0 的预期行为,不能替代运行时验证。 +以上行为与 APISIX 3.18.0 源码一致,但运行时测试尚未完成。在记录 Provider 侧 Chat 与 Embedding 调用次数、完整和中断的 SSE 测试结果、两次从空环境开始的运行结果和脱敏日志,并由其他人独立复现前,状态保持“**验证进行中**”。 -Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. 此社区集成未获得 Redis® 的认可、支持或认证。 +Redis 是 Redis Ltd. 的注册商标。本社区集成与 Redis Ltd. 无隶属关系,也未获得其认可、支持或认证。 diff --git a/website/static/llms.txt b/website/static/llms.txt index 1231c6c0e6f6c..fc0f40f152a05 100644 --- a/website/static/llms.txt +++ b/website/static/llms.txt @@ -36,11 +36,11 @@ ## Integrations & Cookbooks -- [Integration Hub](https://apisix.apache.org/integrations/): Versioned connections between Apache APISIX and external products +- [Integration Hub](https://apisix.apache.org/integrations/): How Apache APISIX connects to external products, with version and testing details - [Apache APISIX with Redis](https://apisix.apache.org/integrations/redis/): Redis-backed AI response caching and shared token counters -- [Cookbooks](https://apisix.apache.org/cookbooks/): Reproducible outcome-focused APISIX guides -- [Redis exact and semantic AI cache](https://apisix.apache.org/cookbooks/redis-ai-cache/): Validate exact hits, semantic matches, isolation, and failures -- [Shared Redis token quota](https://apisix.apache.org/cookbooks/redis-shared-token-quota/): Validate a token counter across APISIX nodes +- [Cookbooks](https://apisix.apache.org/cookbooks/): Step-by-step guides for common Apache APISIX tasks +- [Redis exact and semantic AI cache](https://apisix.apache.org/cookbooks/redis-ai-cache/): Set up and test exact hits, semantic matches, tenant isolation, and Redis failures +- [Shared Redis token quota](https://apisix.apache.org/cookbooks/redis-shared-token-quota/): Share an LLM token counter across APISIX nodes with Redis ## Observability From 2d29a1ac1cd56fd051c18aaf2443479b7eb3a909 Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Fri, 28 Aug 2026 15:26:48 +0800 Subject: [PATCH 8/8] docs(ecosystem): add LocalAI integration guide --- next/src/layouts/EcosystemDetail.astro | 4 +- next/src/lib/ecosystem.ts | 14 +- next/tests/e2e/ecosystem-pages.spec.mjs | 87 +++++++- .../cookbooks/en/localai-chat-completions.md | 187 ++++++++++++++++++ .../cookbooks/zh/localai-chat-completions.md | 175 ++++++++++++++++ website/integrations/en/localai.md | 52 +++++ website/integrations/en/redis.md | 11 +- website/integrations/zh/localai.md | 43 ++++ website/integrations/zh/redis.md | 9 +- website/static/llms.txt | 4 +- 10 files changed, 568 insertions(+), 18 deletions(-) create mode 100644 website/cookbooks/en/localai-chat-completions.md create mode 100644 website/cookbooks/zh/localai-chat-completions.md create mode 100644 website/integrations/en/localai.md create mode 100644 website/integrations/zh/localai.md diff --git a/next/src/layouts/EcosystemDetail.astro b/next/src/layouts/EcosystemDetail.astro index 29f05a4bc52cf..16ab6eb953f3a 100644 --- a/next/src/layouts/EcosystemDetail.astro +++ b/next/src/layouts/EcosystemDetail.astro @@ -142,8 +142,8 @@ const breadcrumbSchema = { {t(locale, 'Verification note:', '验证说明:')}{' '} {t( locale, - 'this page has been checked against the APISIX source, but the pinned lab has not yet passed end-to-end testing.', - '本文已对照 APISIX 源码检查,固定版本的端到端测试尚未完成。', + 'this page has been checked against the APISIX source, but the documented scenario has not yet completed end-to-end runtime validation.', + '本文已对照 APISIX 源码检查,但文中场景尚未完成端到端运行验证。', )} )} diff --git a/next/src/lib/ecosystem.ts b/next/src/lib/ecosystem.ts index b88dbca142149..540e3ce322ea4 100644 --- a/next/src/lib/ecosystem.ts +++ b/next/src/lib/ecosystem.ts @@ -3,8 +3,8 @@ import type { Locale } from './site'; export type LocalizedText = { en: string; zh: string }; export type VerificationStatus = 'verified' | 'documented' | 'validation-in-progress'; -export type IntegrationCategory = 'data'; -export type CookbookCategory = 'cost' | 'reliability'; +export type IntegrationCategory = 'ai-runtime' | 'data'; +export type CookbookCategory = 'cost' | 'deployment' | 'reliability'; interface BaseResource { slug: string; @@ -55,11 +55,13 @@ export const verificationLabels: { [key: VerificationStatus]: LocalizedText } = }; export const integrationCategoryLabels: { [key: IntegrationCategory]: LocalizedText } = { + 'ai-runtime': { en: 'AI model runtimes', zh: 'AI 模型运行时' }, data: { en: 'Data stores, caching, and rate limiting', zh: '数据存储、缓存与限流' }, }; export const cookbookCategoryLabels: { [key: CookbookCategory]: LocalizedText } = { cost: { en: 'Cost and quotas', zh: '成本与配额' }, + deployment: { en: 'Deployment and model serving', zh: '部署与模型服务' }, reliability: { en: 'Reliability', zh: '可靠性' }, }; @@ -127,13 +129,15 @@ function verification(mod: MdModule): VerificationStatus { function integrationCategory(mod: MdModule): IntegrationCategory { const value = requiredString(mod, 'category'); - if (value !== 'data') throw new Error(`${sourceName(mod)}: unsupported integration category ${value}`); - return value; + if (!['ai-runtime', 'data'].includes(value)) { + throw new Error(`${sourceName(mod)}: unsupported integration category ${value}`); + } + return value as IntegrationCategory; } function cookbookCategory(mod: MdModule): CookbookCategory { const value = requiredString(mod, 'category'); - if (!['cost', 'reliability'].includes(value)) { + if (!['cost', 'deployment', 'reliability'].includes(value)) { throw new Error(`${sourceName(mod)}: unsupported cookbook category ${value}`); } return value as CookbookCategory; diff --git a/next/tests/e2e/ecosystem-pages.spec.mjs b/next/tests/e2e/ecosystem-pages.spec.mjs index 3fb6ce5f05fae..1387a82a389c3 100644 --- a/next/tests/e2e/ecosystem-pages.spec.mjs +++ b/next/tests/e2e/ecosystem-pages.spec.mjs @@ -2,13 +2,17 @@ import { expect, test } from '@playwright/test'; const pages = [ '/integrations/', + '/integrations/localai/', '/integrations/redis/', '/cookbooks/', + '/cookbooks/localai-chat-completions/', '/cookbooks/redis-ai-cache/', '/cookbooks/redis-shared-token-quota/', '/zh/integrations/', + '/zh/integrations/localai/', '/zh/integrations/redis/', '/zh/cookbooks/', + '/zh/cookbooks/localai-chat-completions/', '/zh/cookbooks/redis-ai-cache/', '/zh/cookbooks/redis-shared-token-quota/', ]; @@ -64,18 +68,83 @@ test('header navigation reaches an Integration and its Cookbook', async ({ page test('catalogs derive their cards and metadata from Markdown', async ({ page }) => { await page.goto('/integrations/'); await expect(page.getByRole('heading', { level: 1, name: 'Connect APISIX to your stack' })).toBeVisible(); - await expect(page.getByTestId('ecosystem-card')).toHaveCount(1); + await expect(page.getByTestId('ecosystem-card')).toHaveCount(2); + await expect(page.locator('[data-resource="localai"]')).toContainText('Validation in progress'); + await expect(page.locator('[data-resource="localai"]')).toHaveAttribute('href', '/integrations/localai/'); await expect(page.locator('[data-resource="redis"]')).toContainText('Validation in progress'); await expect(page.locator('[data-resource="redis"]')).toHaveAttribute('href', '/integrations/redis/'); await expect(page.locator('[data-resource="redis"] img')).toHaveCount(0); const integrationSchema = await jsonLd(page); - expect(integrationSchema.find((item) => item['@type'] === 'CollectionPage').mainEntity.numberOfItems).toBe(1); + expect(integrationSchema.find((item) => item['@type'] === 'CollectionPage').mainEntity.numberOfItems).toBe(2); await expectNoPageOverflow(page); await page.goto('/zh/cookbooks/'); await expect(page.getByRole('heading', { level: 1, name: 'Apache APISIX 实践指南' })).toBeVisible(); - await expect(page.getByTestId('ecosystem-card')).toHaveCount(2); + await expect(page.getByTestId('ecosystem-card')).toHaveCount(3); + await expect(page.locator('[data-resource="localai-chat-completions"]')).toContainText('通过 APISIX 代理 LocalAI Chat Completions'); await expect(page.locator('[data-resource="redis-ai-cache"]')).toContainText('使用 Redis 缓存 LLM 响应:精确匹配与语义匹配'); + const cookbookSchema = await jsonLd(page); + const cookbookList = cookbookSchema.find((item) => item['@type'] === 'CollectionPage').mainEntity; + expect(cookbookList.numberOfItems).toBe(3); + expect(cookbookList.itemListElement.map((item) => item.url)).toEqual(expect.arrayContaining([ + 'https://apisix.apache.org/zh/cookbooks/localai-chat-completions/', + 'https://apisix.apache.org/zh/cookbooks/redis-ai-cache/', + 'https://apisix.apache.org/zh/cookbooks/redis-shared-token-quota/', + ])); + await expectNoPageOverflow(page); +}); + +test('LocalAI pages expose translations, relationships, and validation boundaries', async ({ page }) => { + test.slow(); + await page.goto('/integrations/localai/'); + await expect(page.getByRole('heading', { level: 1, name: 'LocalAI' })).toBeVisible(); + await expect(page.getByText('Verification note:')).toBeVisible(); + await expect(page.locator('.resource-facts')).toContainText('LocalAI 4.7.1'); + await expect(page.locator('.resource-facts')).toContainText('HTTP, SSE'); + await expect(page.locator('.resource-prose code').filter({ hasText: /^http:\/\/localai:8080$/ }).first()).toBeVisible(); + await expect(page.getByRole('link', { name: 'Proxy LocalAI chat completions with APISIX' })) + .toHaveAttribute('href', '/cookbooks/localai-chat-completions/'); + await expect(page.locator('link[rel="canonical"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/integrations/localai/'); + await expect(page.locator('link[rel="alternate"][hreflang="zh"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/zh/integrations/localai/'); + await expectNoPageOverflow(page); + + await page.goto('/cookbooks/localai-chat-completions/'); + await expect(page.getByRole('heading', { level: 1, name: 'Proxy LocalAI chat completions with APISIX' })).toBeVisible(); + await expect(page.getByRole('heading', { level: 2, name: 'Create the Chat Completions Route' })).toBeVisible(); + await expect(page.locator('pre code').filter({ hasText: '"proxy-rewrite"' })) + .toContainText('xi-api-key'); + await expect(page.getByRole('link', { name: 'LocalAI', exact: true })) + .toHaveAttribute('href', '/integrations/localai/'); + await expect(page.locator('link[rel="canonical"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/cookbooks/localai-chat-completions/'); + await expect(page.locator('link[rel="alternate"][hreflang="zh"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/zh/cookbooks/localai-chat-completions/'); + await expectNoPageOverflow(page); + + await page.goto('/zh/integrations/localai/'); + await expect(page.getByRole('heading', { level: 1, name: 'LocalAI' })).toBeVisible(); + await expect(page.getByRole('heading', { level: 2, name: '接入方式' })).toBeVisible(); + await expect(page.getByRole('link', { name: '通过 APISIX 代理 LocalAI Chat Completions' })) + .toHaveAttribute('href', '/zh/cookbooks/localai-chat-completions/'); + await expect(page.locator('link[rel="canonical"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/zh/integrations/localai/'); + await expect(page.locator('link[rel="alternate"][hreflang="en"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/integrations/localai/'); + await expectNoPageOverflow(page); + + await page.goto('/zh/cookbooks/localai-chat-completions/'); + await expect(page.getByRole('heading', { level: 1, name: '通过 APISIX 代理 LocalAI Chat Completions' })).toBeVisible(); + await expect(page.locator('.resource-facts')).toContainText('30 分钟'); + await expect(page.locator('pre code').filter({ hasText: '"proxy-rewrite"' })) + .toContainText('Cookie'); + await expect(page.getByRole('link', { name: 'LocalAI', exact: true })) + .toHaveAttribute('href', '/zh/integrations/localai/'); + await expect(page.locator('link[rel="canonical"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/zh/cookbooks/localai-chat-completions/'); + await expect(page.locator('link[rel="alternate"][hreflang="en"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/cookbooks/localai-chat-completions/'); await expectNoPageOverflow(page); }); @@ -88,6 +157,8 @@ test('Redis detail pages expose translations, relationships, and source-review b .toHaveAttribute('href', '/cookbooks/redis-ai-cache/'); await expect(page.getByRole('link', { name: 'Share an LLM token quota across APISIX nodes with Redis' })) .toHaveAttribute('href', '/cookbooks/redis-shared-token-quota/'); + await expect(page.locator('tr').filter({ hasText: 'Shared request quota' })) + .toContainText('limit-count'); await expect(page.locator('link[rel="canonical"]')) .toHaveAttribute('href', 'https://apisix.apache.org/integrations/redis/'); await expect(page.locator('link[rel="alternate"][hreflang="zh"]')) @@ -98,6 +169,16 @@ test('Redis detail pages expose translations, relationships, and source-review b expect(schema.some((item) => item['@type'] === 'BreadcrumbList')).toBeTruthy(); await expectNoPageOverflow(page); + await page.goto('/zh/integrations/redis/'); + await expect(page.getByRole('heading', { level: 1, name: 'Redis' })).toBeVisible(); + await expect(page.locator('tr').filter({ hasText: '共享请求配额' })) + .toContainText('limit-count'); + await expect(page.locator('link[rel="canonical"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/zh/integrations/redis/'); + await expect(page.locator('link[rel="alternate"][hreflang="en"]')) + .toHaveAttribute('href', 'https://apisix.apache.org/integrations/redis/'); + await expectNoPageOverflow(page); + await page.goto('/zh/cookbooks/redis-shared-token-quota/'); await expect(page.getByRole('heading', { level: 1, name: '使用 Redis 在多个 APISIX 节点间共享 LLM token 配额' })).toBeVisible(); await expect(page.locator('.breadcrumbs').getByRole('link', { name: 'Cookbook', exact: true })) diff --git a/website/cookbooks/en/localai-chat-completions.md b/website/cookbooks/en/localai-chat-completions.md new file mode 100644 index 0000000000000..40eef7309d48f --- /dev/null +++ b/website/cookbooks/en/localai-chat-completions.md @@ -0,0 +1,187 @@ +--- +title: Proxy LocalAI chat completions with APISIX +slug: localai-chat-completions +description: Put APISIX 3.18 in front of a private LocalAI Chat Completions endpoint while keeping client and upstream credentials separate. +category: deployment +verification: validation-in-progress +owner: Apache APISIX community +difficulty: Intermediate +duration: 30 minutes +apisix_version: 3.18.0 +external_version: LocalAI 4.7.1 +integrations: + - localai +plugins: + - ai-proxy + - key-auth + - proxy-rewrite +reviewed_at: "2026-08-28" +evidence_url: https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-providers/openai-compatible.lua +--- + +This guide creates one public Chat Completions Route. Clients authenticate to APISIX with an OpenAI-style bearer token; APISIX removes that token and sends a separate LocalAI credential upstream. + +## Before you start + +You need APISIX 3.18.0, LocalAI 4.7.1 with a chat model installed, and network access from APISIX to `localai:8080`. Keep the LocalAI port private. From an operator shell on that network, list the available model aliases: + +```shell +curl "http://localai:8080/v1/models" \ + -H "Authorization: Bearer " +``` + +Choose an ID returned by this call. This Cookbook uses `` as the placeholder. + +Make the full LocalAI authorization value available to the APISIX process before it starts: + +```text +LOCALAI_AUTHORIZATION=Bearer +``` + +The Route below reads it through `$ENV://LOCALAI_AUTHORIZATION`, so the credential is not stored in the Route document. + +If this value comes from LocalAI's legacy `LOCALAI_API_KEY`, treat it as a full-administration credential. Prefer LocalAI user authentication with a least-privilege API key when available. + +## Create a gateway client + +Create a Consumer and a `key-auth` Credential. Store the complete bearer value because `key-auth` compares the header value as-is: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/consumers" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "username": "localai-client" + }' + +curl "http://127.0.0.1:9180/apisix/admin/consumers/localai-client/credentials" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "cred-localai-key-auth", + "plugins": { + "key-auth": { + "key": "Bearer " + } + } + }' +``` + +Use a real secret reference for the Consumer credential in production. + +## Create the Chat Completions Route + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "localai-chat", + "uri": "/v1/chat/completions", + "methods": ["POST"], + "plugins": { + "key-auth": { + "header": "Authorization", + "hide_credentials": true + }, + "proxy-rewrite": { + "headers": { + "remove": ["Cookie", "x-api-key", "xi-api-key"] + } + }, + "ai-proxy": { + "provider": "openai-compatible", + "auth": { + "header": { + "Authorization": "$ENV://LOCALAI_AUTHORIZATION" + } + }, + "override": { + "endpoint": "http://localai:8080" + }, + "timeout": 300000 + } + } + }' +``` + +Use the LocalAI origin as the endpoint. APISIX adds `/v1/chat/completions` for this request format. If the endpoint contains `/v1`, APISIX uses that path as-is instead. + +The `proxy-rewrite` block removes the cookie and API-key headers that LocalAI also accepts for authentication. Without this step, a client-supplied LocalAI credential could take precedence over the identity configured by the gateway. + +This Route leaves `options.model` unset, so the client model reaches LocalAI. To expose only one model, add the following block to `ai-proxy`; APISIX then overwrites any client model: + +```json +"options": { + "model": "" +} +``` + +## Send requests + +Test a non-streaming response: + +```shell +curl "http://127.0.0.1:9080/v1/chat/completions" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "", + "messages": [{"role": "user", "content": "Reply with one short sentence."}] + }' +``` + +Then test SSE: + +```shell +curl --no-buffer "http://127.0.0.1:9080/v1/chat/completions" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "", + "messages": [{"role": "user", "content": "Count from one to five."}], + "stream": true + }' +``` + +An OpenAI SDK can use the same Route: + +```python +from openai import OpenAI + +client = OpenAI( + base_url="https://gateway.example.com/v1", + api_key="", +) + +response = client.chat.completions.create( + model="", + messages=[{"role": "user", "content": "Hello from APISIX"}], +) +print(response.choices[0].message.content) +``` + +## Check the boundaries + +| Check | Expected result | +|---|---| +| Missing or wrong gateway key | APISIX returns `401` without calling LocalAI. | +| Valid gateway key | APISIX removes the client credential and other LocalAI authentication headers; LocalAI receives only the configured bearer identity. | +| Wrong LocalAI credential | The request reaches LocalAI and fails authentication; do not retry it against another credential automatically. | +| Streaming request | The response is SSE and ends with LocalAI's normal terminal event. A missing terminal event means the stream is incomplete. | +| Another LocalAI path | It does not match this Route. Model listing and management stay private. | + +Keep `logging.payloads` disabled unless prompts and responses are approved for logging. Set an explicit stream-duration and response-size limit for production workloads, monitor upstream latency and errors, and use TLS with certificate verification when the LocalAI connection leaves a trusted network. + +## Validation note + +The [merged LocalAI documentation](https://github.com/mudler/LocalAI/pull/11294) and [APISIX documentation PR #13770](https://github.com/apache/apisix/pull/13770) cover the transparent proxy path, including a recorded APISIX 3.17.0 and LocalAI 4.7.1 run. This native APISIX 3.18 Route is source-reviewed but not runtime-verified yet. Before changing the status, run non-streaming and SSE requests twice from a clean environment, verify both authentication layers, and have another operator reproduce the result. + +## Cleanup + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/localai-chat" -X DELETE \ + -H "X-API-KEY: ${admin_key}" + +curl "http://127.0.0.1:9180/apisix/admin/consumers/localai-client" -X DELETE \ + -H "X-API-KEY: ${admin_key}" +``` + +Remove the LocalAI authorization variable from the APISIX deployment when it is no longer used. diff --git a/website/cookbooks/zh/localai-chat-completions.md b/website/cookbooks/zh/localai-chat-completions.md new file mode 100644 index 0000000000000..a2fab4357b083 --- /dev/null +++ b/website/cookbooks/zh/localai-chat-completions.md @@ -0,0 +1,175 @@ +--- +title: 通过 APISIX 代理 LocalAI Chat Completions +slug: localai-chat-completions +translation_of: localai-chat-completions +description: 在私有 LocalAI Chat Completions 接口前部署 APISIX 3.18,并分开管理客户端与上游凭据。 +difficulty: 中等 +duration: 30 分钟 +--- + +本指南只创建一条对外的 Chat Completions Route。客户端使用 OpenAI 风格的 Bearer Token 向 APISIX 认证;APISIX 删除该 Token,再使用另一份 LocalAI 凭据访问上游。 + +## 准备工作 + +准备 APISIX 3.18.0、已安装 Chat 模型的 LocalAI 4.7.1,并确保 APISIX 能访问 `localai:8080`。LocalAI 端口不应公开。先在同一私有网络的运维终端中查询模型别名: + +```shell +curl "http://localai:8080/v1/models" \ + -H "Authorization: Bearer " +``` + +从返回结果中选择模型 ID。下文使用 `` 作为占位符。 + +启动 APISIX 前,把完整的 LocalAI Authorization 值注入 APISIX 进程: + +```text +LOCALAI_AUTHORIZATION=Bearer +``` + +Route 通过 `$ENV://LOCALAI_AUTHORIZATION` 读取该值,避免把凭据明文保存在 Route 文档中。 + +如果该值来自 LocalAI 旧版 `LOCALAI_API_KEY`,应将其视为完整管理凭据。条件允许时,优先启用 LocalAI 用户认证并使用最小权限的 API key。 + +## 创建网关客户端 + +创建 Consumer 和 `key-auth` Credential。`key-auth` 会按原值比较 header,因此这里保存完整的 Bearer 值: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/consumers" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "username": "localai-client" + }' + +curl "http://127.0.0.1:9180/apisix/admin/consumers/localai-client/credentials" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "cred-localai-key-auth", + "plugins": { + "key-auth": { + "key": "Bearer " + } + } + }' +``` + +生产环境中的 Consumer 凭据应改用真实的 Secret 引用。 + +## 创建 Chat Completions Route + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "localai-chat", + "uri": "/v1/chat/completions", + "methods": ["POST"], + "plugins": { + "key-auth": { + "header": "Authorization", + "hide_credentials": true + }, + "proxy-rewrite": { + "headers": { + "remove": ["Cookie", "x-api-key", "xi-api-key"] + } + }, + "ai-proxy": { + "provider": "openai-compatible", + "auth": { + "header": { + "Authorization": "$ENV://LOCALAI_AUTHORIZATION" + } + }, + "override": { + "endpoint": "http://localai:8080" + }, + "timeout": 300000 + } + } + }' +``` + +Endpoint 应填写 LocalAI origin。APISIX 会根据请求格式追加 `/v1/chat/completions`;如果 endpoint 已包含 `/v1`,APISIX 会直接使用该路径,不再追加。 + +`proxy-rewrite` 会移除 LocalAI 同样接受的 Cookie 和 API key header。缺少这一步时,客户端传入的 LocalAI 凭据可能优先于网关配置的身份。 + +上面的 Route 没有设置 `options.model`,客户端模型会传给 LocalAI。若只允许一个模型,可以在 `ai-proxy` 中添加以下配置;此时 APISIX 会覆盖客户端传入的模型: + +```json +"options": { + "model": "" +} +``` + +## 发送请求 + +先测试非流式响应: + +```shell +curl "http://127.0.0.1:9080/v1/chat/completions" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "", + "messages": [{"role": "user", "content": "请用一句话回答。"}] + }' +``` + +再测试 SSE: + +```shell +curl --no-buffer "http://127.0.0.1:9080/v1/chat/completions" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "", + "messages": [{"role": "user", "content": "从一数到五。"}], + "stream": true + }' +``` + +OpenAI SDK 也可以使用同一条 Route: + +```python +from openai import OpenAI + +client = OpenAI( + base_url="https://gateway.example.com/v1", + api_key="", +) + +response = client.chat.completions.create( + model="", + messages=[{"role": "user", "content": "Hello from APISIX"}], +) +print(response.choices[0].message.content) +``` + +## 检查边界 + +| 检查 | 预期结果 | +|---|---| +| 网关 key 缺失或错误 | APISIX 返回 `401`,且不调用 LocalAI。 | +| 网关 key 正确 | APISIX 移除客户端凭据和其他 LocalAI 认证 header;LocalAI 只收到网关配置的 Bearer 身份。 | +| LocalAI 凭据错误 | 请求到达 LocalAI 后认证失败;不要自动换用另一份凭据重试。 | +| 流式请求 | 返回 SSE,并以 LocalAI 的正常终止事件结束。缺少终止事件表示流不完整。 | +| 其他 LocalAI 路径 | 不匹配此 Route,模型列表和管理接口仍保持私有。 | + +除非 Prompt 和响应已经获准写入日志,否则不要启用 `logging.payloads`。生产环境还应设置明确的流式时长与响应大小上限,监控上游延迟和错误;若 LocalAI 连接离开可信网络,应使用 TLS 并校验证书。 + +## 验证说明 + +[LocalAI 已合并的文档](https://github.com/mudler/LocalAI/pull/11294)和 [APISIX 文档 PR #13770](https://github.com/apache/apisix/pull/13770)覆盖透明代理链路,后者记录了 APISIX 3.17.0 与 LocalAI 4.7.1 的真实运行结果。本文的 APISIX 3.18 原生 Route 已完成源码核对,但尚未完成运行时验证。更新状态前,需要从干净环境运行两次非流式与 SSE 请求,验证两层认证,并由另一位操作者复现。 + +## 清理 + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/localai-chat" -X DELETE \ + -H "X-API-KEY: ${admin_key}" + +curl "http://127.0.0.1:9180/apisix/admin/consumers/localai-client" -X DELETE \ + -H "X-API-KEY: ${admin_key}" +``` + +不再使用该集成后,从 APISIX 部署中移除 LocalAI authorization 环境变量。 diff --git a/website/integrations/en/localai.md b/website/integrations/en/localai.md new file mode 100644 index 0000000000000..c9106b89d229b --- /dev/null +++ b/website/integrations/en/localai.md @@ -0,0 +1,52 @@ +--- +title: LocalAI +slug: localai +description: Route OpenAI-compatible chat traffic from APISIX 3.18 to a private LocalAI deployment, with separate client and upstream authentication. +category: ai-runtime +method: ai-proxy openai-compatible provider +verification: validation-in-progress +owner: Apache APISIX community +apisix_version: 3.18.0 +external_version: LocalAI 4.7.1 +protocols: + - HTTP + - SSE +reviewed_at: "2026-08-28" +evidence_url: https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-providers/openai-compatible.lua +--- + +LocalAI serves models on your own infrastructure through OpenAI-compatible APIs. Apache APISIX 3.18 can send Chat Completions requests to LocalAI with the `ai-proxy` plugin while keeping client authentication, upstream credentials, limits, and logging at the gateway. + +
+ OpenAI-compatible clientApache APISIXLocalAI +
+ +LocalAI now includes an [Apache APISIX reverse-proxy example](https://github.com/mudler/LocalAI/blob/d85577ff5c6f7cbc5a49c13ab5013a1ecfabf26c/docs/content/advanced/reverse-proxy-tls.md#apache-apisix-configuration). That example is a transparent proxy. The related Cookbook on this site uses the APISIX 3.18 `openai-compatible` provider instead. + +## How the connection works + +| Setting | Behavior | +|---|---| +| Endpoint | Set `override.endpoint` to the LocalAI origin, for example `http://localai:8080`. APISIX selects `/v1/chat/completions` for a Chat Completions body. Do not set the endpoint to `http://localai:8080/v1`: an endpoint path is used as-is and would suppress the provider path. | +| Model | Set `options.model` to pin one installed LocalAI model and override the client's value. Omit it when clients may select among allowed LocalAI model aliases. | +| Authentication | Authenticate clients separately, for example with `key-auth`. Configure the LocalAI credential in `ai-proxy.auth.header.Authorization`; that configured value replaces a client `Authorization` header before the upstream request. | +| Streaming | A request with `stream: true` is handled as SSE. APISIX adds the OpenAI usage-stream option and forwards the LocalAI stream to the client. | + +The `openai-compatible` provider also defines OpenAI Responses and Embeddings paths. This page only covers Chat Completions; test the other LocalAI endpoints separately before enabling them. + +## Keep the surface small + +Expose only the Chat Completions Route needed by applications. `ai-proxy` does not turn the Route into a general LocalAI proxy, so `/v1/models`, the Web UI, model installation, and LocalAI management endpoints remain unavailable unless you create separate Routes for them. + +Keep LocalAI on a private network reachable by APISIX. Store its authorization value in an APISIX-supported secret reference, keep payload logging disabled unless you have a reviewed data-handling policy, and use TLS with certificate verification when LocalAI is reached across an untrusted network. Because `ai-proxy` forwards other client headers, remove `Cookie`, `x-api-key`, and `xi-api-key` on Routes that use a dedicated LocalAI identity. LocalAI's legacy `LOCALAI_API_KEY` grants full administrative access without role separation; prefer a least-privilege user API key when LocalAI authentication is enabled. + +## Verification status + +The merged LocalAI guide and [APISIX documentation PR #13770](https://github.com/apache/apisix/pull/13770) cover a transparent APISIX proxy. The latter records a real run with APISIX 3.17.0 and LocalAI 4.7.1. The APISIX 3.18 `openai-compatible` configuration on this site has been checked against the [3.18.0 provider source](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-providers/openai-compatible.lua), but it has not yet completed the same clean runtime test. The status therefore remains **Validation in progress**. + +## References + +- [APISIX `ai-proxy` documentation](/docs/apisix/plugins/ai-proxy/) +- [LocalAI 4.7.1 quickstart](https://github.com/mudler/LocalAI/blob/b224c96db6f4b87306a33a808650bfce63b12588/docs/content/getting-started/quickstart.md) +- [LocalAI 4.7.1 authentication](https://github.com/mudler/LocalAI/blob/b224c96db6f4b87306a33a808650bfce63b12588/docs/content/features/authentication.md) +- [Merged LocalAI APISIX documentation PR](https://github.com/mudler/LocalAI/pull/11294) diff --git a/website/integrations/en/redis.md b/website/integrations/en/redis.md index 3f57f2ff77ad1..2e8adb6efa7d6 100644 --- a/website/integrations/en/redis.md +++ b/website/integrations/en/redis.md @@ -1,7 +1,7 @@ --- title: Redis slug: redis -description: Use Redis for exact and semantic response caching in APISIX AI Gateway, or to share token counters across APISIX nodes. +description: Use Redis for AI response caching, shared token counters, and request quotas across APISIX nodes. category: data method: Built-in APISIX plugins verification: validation-in-progress @@ -10,11 +10,11 @@ apisix_version: 3.18.0 external_version: Redis Open Source 8.10.1 protocols: - RESP -reviewed_at: "2026-08-25" +reviewed_at: "2026-08-28" evidence_url: https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache.lua --- -Apache APISIX 3.18 uses Redis in two different ways. `ai-cache` stores reusable LLM responses, while `ai-rate-limiting` stores token counters shared by multiple APISIX nodes. These features have different deployment and failure characteristics, so configure and operate them separately. +Apache APISIX 3.18 can use Redis for reusable LLM responses, shared token counters, and distributed request quotas. These features have different deployment and failure characteristics, so configure and operate them separately.
AI clientApache APISIXRedis @@ -27,6 +27,7 @@ Apache APISIX 3.18 uses Redis in two different ways. `ai-cache` stores reusable | Exact response cache | Redis stores the body of an HTTP 200 AI response. The key includes a normalized request body and configured provider options. The default TTL is 3,600 seconds and the default maximum response is 1 MiB. | Forwarded headers are not part of the default key. If a header can change the provider response, remove it or map it to a trusted server-side variable and include that variable in `cache_key.include_vars`. APISIX 3.18 supports one Redis endpoint for the cache. It does not coalesce concurrent cache misses, honor upstream `Cache-Control`, or provide a purge API. A hit restores HTTP 200 and `Content-Type`; other upstream response headers are not replayed. | | Semantic response cache | After an exact miss, APISIX can embed a plain-text OpenAI Chat prompt and query Redis Search for a similar cached response. | Semantic matching is limited to plain-text OpenAI Chat requests. Multimodal requests and non-empty tool or function calls bypass this layer. The exact layer remains enabled. | | Shared token quota | `ai-rate-limiting` can store a fixed-window token counter in a Redis database so multiple APISIX nodes see the same usage. | Accounting happens after a model response supplies token usage. It is not a prepaid reservation: a large or concurrent response can cross the limit before a later request is rejected. | +| Shared request quota | `limit-count` with `policy: redis` stores request counters in Redis so different APISIX nodes use the same quota. | Choose the counter key from trusted state. Fixed and sliding windows are available. `sync_interval` reduces Redis round trips but lets the global count lag between synchronizations. The companion lab has not tested this path. | | Streaming cache | A complete supported SSE response can be cached after APISIX sees the protocol terminal event. | Interrupted streams are not cached. JSON and SSE use separate entries. A cache hit replays the complete stored SSE immediately; it does not reproduce the original token cadence. | Semantic caching requires Redis Search commands. The companion lab pins Redis Open Source 8.10.1 and checks that `FT._LIST` is available. This preflight confirms the Redis setup, not the LLM cache path. If you use an earlier Redis Open Source or Redis Stack release, pin and test that exact version. @@ -41,6 +42,7 @@ Store Redis credentials in an APISIX Secret and keep Redis on a private network. - Cache, vector-search, and embedding errors degrade to a cache miss, so APISIX continues to the LLM. A `MISS` header alone does not prove the Redis service is healthy. - Shared quota behaves differently. A pre-request Redis check returns an error when `allow_degradation: false`; when set to `true`, APISIX lets the request continue without quota protection. This setting applies only to the pre-request check. If Redis fails before the log-phase write completes, the response may succeed without recording its token usage. Monitor APISIX for write errors. +- `limit-count` also follows `allow_degradation` when its Redis dependency fails. Redis Cluster and Sentinel policies are documented and covered by upstream tests, but this page does not claim their failover behavior was validated in the companion lab. - A cache hit returns before `ai-rate-limiting` runs. It avoids an upstream model call and does not increase the Redis token counter. ## Observability @@ -53,9 +55,10 @@ The APISIX Prometheus plugin exports cache hits by `exact` or `semantic` layer, - [`ai-cache` schema](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/schema.lua) - [Semantic-cache implementation](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/semantic.lua) - [`ai-rate-limiting` source](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-rate-limiting.lua) +- [`limit-count` source](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/limit-count.lua) - [Redis Search module lifecycle](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/modules-lifecycle/) - [Pinned two-node APISIX and Redis lab](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway) -The behavior above matches the APISIX 3.18.0 source, but runtime testing is not complete. Keep this page marked **Validation in progress** until the lab records provider-side chat and embedding call counts, complete and interrupted SSE results, two clean runs, sanitized logs, and a rerun by another operator. +The behavior above matches the APISIX 3.18.0 source, but runtime testing is not complete. Keep this page marked **Validation in progress** until the lab records provider-side chat and embedding call counts, complete and interrupted SSE results, two clean runs, sanitized logs, a rerun by another operator, and a separate validation of the Redis-backed `limit-count` request-quota path. Redis is a registered trademark of Redis Ltd. This community integration is not endorsed, supported, or certified by Redis Ltd. diff --git a/website/integrations/zh/localai.md b/website/integrations/zh/localai.md new file mode 100644 index 0000000000000..59e1548fa8dec --- /dev/null +++ b/website/integrations/zh/localai.md @@ -0,0 +1,43 @@ +--- +title: LocalAI +slug: localai +translation_of: localai +description: 通过 APISIX 3.18 将 OpenAI 兼容的 Chat 请求转发到私有 LocalAI,并分开管理客户端认证与上游认证。 +method: ai-proxy openai-compatible Provider +--- + +LocalAI 可在自有基础设施上运行模型,并提供 OpenAI 兼容 API。Apache APISIX 3.18 可以通过 `ai-proxy` 将 Chat Completions 请求发送到 LocalAI,同时在网关侧统一处理客户端认证、上游凭据、限流和日志。 + +
+ OpenAI 兼容客户端Apache APISIXLocalAI +
+ +LocalAI 已合并 [Apache APISIX 反向代理示例](https://github.com/mudler/LocalAI/blob/d85577ff5c6f7cbc5a49c13ab5013a1ecfabf26c/docs/content/advanced/reverse-proxy-tls.md#apache-apisix-configuration)。该示例使用透明代理;本站的相关 Cookbook 则使用 APISIX 3.18 的 `openai-compatible` Provider。 + +## 接入方式 + +| 配置 | 行为 | +|---|---| +| Endpoint | 将 `override.endpoint` 设为 LocalAI 的 origin,例如 `http://localai:8080`。APISIX 会根据 Chat Completions 请求正文选择 `/v1/chat/completions`。不要写成 `http://localai:8080/v1`,因为 endpoint 中已有的路径会被原样使用,不会再追加 Provider 路径。 | +| 模型 | 设置 `options.model` 可固定一个已安装的 LocalAI 模型,并覆盖客户端传入的值。若允许客户端从许可的模型别名中选择,则省略该字段。 | +| 认证 | 客户端可以使用 `key-auth` 等插件单独认证。LocalAI 凭据配置在 `ai-proxy.auth.header.Authorization` 中;调用上游时,这个值会替换客户端传入的 `Authorization` header。 | +| 流式响应 | 请求正文包含 `stream: true` 时,APISIX 按 SSE 处理,补充 OpenAI 的流式 usage 选项,并把 LocalAI 的流转发给客户端。 | + +`openai-compatible` Provider 还定义了 OpenAI Responses 与 Embeddings 路径。本文只覆盖 Chat Completions;启用其他 LocalAI 接口前,需要分别验证。 + +## 控制暴露面 + +只开放应用需要的 Chat Completions Route。`ai-proxy` 不会把这条 Route 变成 LocalAI 的通用反向代理,因此 `/v1/models`、Web UI、模型安装与管理接口不会自动暴露;如确有需要,应单独创建并保护 Route。 + +LocalAI 应位于 APISIX 可访问的私有网络。其认证值应使用 APISIX 支持的 Secret 引用保存;除非已有评审通过的数据处理策略,否则不要启用正文日志。若 APISIX 需要跨不可信网络访问 LocalAI,应启用 TLS 并校验证书。由于 `ai-proxy` 会转发其他客户端 header,使用独立 LocalAI 身份的 Route 应移除 `Cookie`、`x-api-key` 和 `xi-api-key`。LocalAI 旧版 `LOCALAI_API_KEY` 拥有完整管理权限且没有角色隔离;启用 LocalAI 认证时,应优先使用最小权限的用户 API key。 + +## 验证状态 + +LocalAI 已合并的文档和 [APISIX 文档 PR #13770](https://github.com/apache/apisix/pull/13770) 使用透明代理;后者记录了 APISIX 3.17.0 与 LocalAI 4.7.1 的真实运行结果。本站的 APISIX 3.18 `openai-compatible` 配置已根据 [3.18.0 Provider 源码](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-providers/openai-compatible.lua) 核对,但尚未完成同等的干净环境运行测试,因此状态保持“**验证进行中**”。 + +## 参考资料 + +- [APISIX `ai-proxy` 文档](/zh/docs/apisix/plugins/ai-proxy/) +- [LocalAI 4.7.1 快速入门](https://github.com/mudler/LocalAI/blob/b224c96db6f4b87306a33a808650bfce63b12588/docs/content/getting-started/quickstart.md) +- [LocalAI 4.7.1 认证文档](https://github.com/mudler/LocalAI/blob/b224c96db6f4b87306a33a808650bfce63b12588/docs/content/features/authentication.md) +- [已合并的 LocalAI APISIX 文档 PR](https://github.com/mudler/LocalAI/pull/11294) diff --git a/website/integrations/zh/redis.md b/website/integrations/zh/redis.md index eeb8ed6fe8104..2ca145eb82993 100644 --- a/website/integrations/zh/redis.md +++ b/website/integrations/zh/redis.md @@ -2,11 +2,11 @@ title: Redis slug: redis translation_of: redis -description: 在 APISIX AI Gateway 中使用 Redis 缓存 LLM 响应,或在多个 APISIX 节点间共享 token 计数。 +description: 在多个 APISIX 节点间使用 Redis 共享 AI 响应缓存、Token 计数和请求配额。 method: APISIX 内置插件 --- -Apache APISIX 3.18 以两种不同方式使用 Redis:`ai-cache` 保存可复用的 LLM 响应,`ai-rate-limiting` 保存多个 APISIX 节点共享的 token 计数。两者的部署方式和故障处理不同,需要分别配置和运维。 +Apache APISIX 3.18 可使用 Redis 保存可复用的 LLM 响应、共享 Token 计数和分布式请求配额。这些能力的部署方式与故障处理不同,需要分别配置和运维。
AI 客户端Apache APISIXRedis @@ -19,6 +19,7 @@ Apache APISIX 3.18 以两种不同方式使用 Redis:`ai-cache` 保存可复 | 精确响应缓存 | Redis 保存 HTTP 200 AI 响应的正文。缓存键包含规范化后的请求正文和 Provider 配置。默认 TTL 为 3,600 秒,默认最大响应为 1 MiB。 | 默认缓存键不包含转发 header。应移除客户端可控的 Provider 路由 header;如果其他值也会影响响应,应从可信服务端变量取值,并通过 `cache_key.include_vars` 加入缓存键。APISIX 3.18 的缓存只支持一个 Redis endpoint,不提供请求合并,也不处理上游 `Cache-Control`,没有专用清理 API。命中时会恢复 HTTP 200 与 `Content-Type`,其他上游响应 header 不会回放。 | | 语义响应缓存 | 精确缓存未命中后,APISIX 可为纯文本 OpenAI Chat prompt 生成 embedding,并通过 Redis Search 查找相似响应。 | 语义匹配只适用于纯文本 OpenAI Chat。多模态请求和非空 tool/function call 会绕过这一层;精确缓存层始终启用。 | | 共享 token 配额 | `ai-rate-limiting` 可把固定窗口计数保存在 Redis 数据库中,使多个 APISIX 节点看到同一份用量。 | 只有模型响应给出 usage 后才记账,并非预付式额度预留;大响应或并发响应可能先越过阈值,后续请求才被拒绝。 | +| 共享请求配额 | `limit-count` 使用 `policy: redis` 时,会把请求计数保存在 Redis 中,使不同 APISIX 节点共用一份配额。 | 计数键应来自可信状态。插件支持固定窗口和滑动窗口;`sync_interval` 可减少 Redis 往返,但同步间隔内的全局计数会暂时滞后。配套实验尚未测试这条链路。 | | 流式缓存 | APISIX 识别到协议终止事件后,可缓存完整、受支持的 SSE 响应。 | 中断的流不会写入缓存;JSON 与 SSE 使用不同条目;命中时会立即回放完整 SSE,不会复现原始 token 节奏。 | 语义缓存需要 Redis Search 命令。配套实验固定使用 Redis Open Source 8.10.1,并检查 `FT._LIST` 是否可用。这个预检只能确认 Redis 环境正常,不能验证 LLM 缓存链路。若使用更早的 Redis Open Source 或 Redis Stack 版本,需要固定并测试具体版本。 @@ -33,6 +34,7 @@ Redis 凭据应使用 APISIX Secret 引用。Redis 服务应位于私有网络 - 缓存、向量搜索或 embedding 出错时会降级为缓存未命中,APISIX 继续请求 LLM。因此,仅看到 `MISS` header 不能证明 Redis 服务健康。 - 共享配额的处理方式不同。请求前 Redis 配额检查失败时,`allow_degradation: false` 会返回错误;设为 `true` 时会放行请求,但不再提供配额保护。这个开关只影响请求前检查。如果 Redis 在 access 阶段检查完成后、log 阶段异步写入计数前发生故障,当前响应仍可能成功,Token 用量也可能未写入;应监控写入错误。 +- `limit-count` 的 Redis 依赖发生故障时,也会按 `allow_degradation` 处理。Redis Cluster 和 Sentinel policy 已有官方文档与上游测试,但本文不声称配套实验验证了故障切换。 - 缓存命中会在 `ai-rate-limiting` 之前直接返回,因此不会调用上游模型,也不会增加 Redis token 计数。 ## 可观测性 @@ -45,9 +47,10 @@ APISIX Prometheus 插件会导出按 `exact` 或 `semantic` 分层的命中数 - [`ai-cache` Schema](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/schema.lua) - [语义缓存实现](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-cache/semantic.lua) - [`ai-rate-limiting` 源码](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/ai-rate-limiting.lua) +- [`limit-count` 源码](https://github.com/apache/apisix/blob/0796d9c2cbedb1f8bf8194292ff526599f4fde20/apisix/plugins/limit-count.lua) - [Redis Search 模块生命周期](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/modules-lifecycle/) - [固定版本的双节点 APISIX 与 Redis 实验](https://github.com/apache/apisix-website/tree/master/examples/redis-ai-gateway) -以上行为与 APISIX 3.18.0 源码一致,但运行时测试尚未完成。在记录 Provider 侧 Chat 与 Embedding 调用次数、完整和中断的 SSE 测试结果、两次从空环境开始的运行结果和脱敏日志,并由其他人独立复现前,状态保持“**验证进行中**”。 +以上行为与 APISIX 3.18.0 源码一致,但运行时测试尚未完成。在记录 Provider 侧 Chat 与 Embedding 调用次数、完整和中断的 SSE 测试结果、两次从空环境开始的运行结果和脱敏日志,由其他人独立复现,并单独验证基于 Redis 的 `limit-count` 请求配额链路前,状态保持“**验证进行中**”。 Redis 是 Redis Ltd. 的注册商标。本社区集成与 Redis Ltd. 无隶属关系,也未获得其认可、支持或认证。 diff --git a/website/static/llms.txt b/website/static/llms.txt index fc0f40f152a05..227d3ac96a2b2 100644 --- a/website/static/llms.txt +++ b/website/static/llms.txt @@ -37,8 +37,10 @@ ## Integrations & Cookbooks - [Integration Hub](https://apisix.apache.org/integrations/): How Apache APISIX connects to external products, with version and testing details -- [Apache APISIX with Redis](https://apisix.apache.org/integrations/redis/): Redis-backed AI response caching and shared token counters +- [Apache APISIX with LocalAI](https://apisix.apache.org/integrations/localai/): Route OpenAI-compatible LocalAI chat traffic through Apache APISIX +- [Apache APISIX with Redis](https://apisix.apache.org/integrations/redis/): Redis-backed AI response caching, shared token counters, and distributed request quotas - [Cookbooks](https://apisix.apache.org/cookbooks/): Step-by-step guides for common Apache APISIX tasks +- [LocalAI chat completions](https://apisix.apache.org/cookbooks/localai-chat-completions/): Put APISIX in front of a private LocalAI chat endpoint - [Redis exact and semantic AI cache](https://apisix.apache.org/cookbooks/redis-ai-cache/): Set up and test exact hits, semantic matches, tenant isolation, and Redis failures - [Shared Redis token quota](https://apisix.apache.org/cookbooks/redis-shared-token-quota/): Share an LLM token counter across APISIX nodes with Redis