From 86570ca74ad222e0d1f1a407e3290940aafcc71a Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Wed, 22 Jul 2026 15:47:56 +0300 Subject: [PATCH 01/22] feat(ai): add the AI gateway, resource, controllers, and emitters Introduce built-in AI: an `ai` resource that provisions keyless, in-account model access through an embedded gateway. Adds the Rust gateway engine and napi addon, the TypeScript SDK wrapper, the per-cloud controllers and setup emitters (AWS Bedrock, GCP Vertex, Azure Foundry), the ai/invoke permission sets, and quickstart examples. --- .github/workflows/ci-fast.yml | 11 +- .github/workflows/release.yml | 120 +- Cargo.lock | 121 +- Cargo.toml | 7 + crates/alien-ai-gateway-node/.gitignore | 10 + crates/alien-ai-gateway-node/Cargo.toml | 20 + crates/alien-ai-gateway-node/build.rs | 18 + crates/alien-ai-gateway-node/package.json | 19 + crates/alien-ai-gateway-node/src/error.rs | 37 + crates/alien-ai-gateway-node/src/lib.rs | 52 + .../src/azure/cognitive_services.rs | 666 ++++++ crates/alien-azure-clients/src/azure/mod.rs | 1 + crates/alien-azure-clients/src/lib.rs | 3 + crates/alien-bindings/src/provider.rs | 10 +- crates/alien-bindings/src/traits.rs | 10 +- crates/alien-cli/src/commands/init.rs | 8 + crates/alien-cloudformation/src/built_ins.rs | 12 +- .../src/emitters/aws/ai.rs | 145 ++ .../src/emitters/aws/mod.rs | 2 + .../alien-cloudformation/tests/generator.rs | 1 + .../tests/generator/aws_ai_tests.rs | 77 + ...s_ai_tests__aws_ai_invoke_permissions.snap | 191 ++ crates/alien-core/src/ai_catalog.rs | 381 ++++ crates/alien-core/src/bin/schema_exporter.rs | 2 + crates/alien-core/src/bindings/ai.rs | 193 ++ crates/alien-core/src/bindings/mod.rs | 4 + crates/alien-core/src/external_bindings.rs | 63 +- crates/alien-core/src/heartbeat.rs | 126 + crates/alien-core/src/import/data/aws/ai.rs | 11 + crates/alien-core/src/import/data/aws/mod.rs | 2 + crates/alien-core/src/import/data/azure/ai.rs | 48 + .../alien-core/src/import/data/azure/mod.rs | 2 + crates/alien-core/src/import/data/gcp/ai.rs | 35 + crates/alien-core/src/import/data/gcp/mod.rs | 2 + crates/alien-core/src/import/data/mod.rs | 12 +- crates/alien-core/src/lib.rs | 1 + crates/alien-core/src/ownership.rs | 4 +- crates/alien-core/src/resource.rs | 10 + crates/alien-core/src/resources/ai.rs | 172 ++ crates/alien-core/src/resources/mod.rs | 3 + crates/alien-deployment/src/initial_setup.rs | 11 +- crates/alien-gateway/Cargo.toml | 37 + .../alien-gateway/src/bin/alien-ai-gateway.rs | 98 + crates/alien-gateway/src/config.rs | 366 +++ crates/alien-gateway/src/creds.rs | 515 +++++ crates/alien-gateway/src/error.rs | 102 + crates/alien-gateway/src/lib.rs | 178 ++ crates/alien-gateway/src/router.rs | 2017 +++++++++++++++++ crates/alien-gateway/tests/integration.rs | 155 ++ crates/alien-gateway/tests/launcher.rs | 59 + crates/alien-gateway/tests/live_bedrock.rs | 148 ++ .../tests/live_foundry_claude.rs | 112 + .../alien-gateway/tests/live_vertex_claude.rs | 104 + crates/alien-infra/src/ai/aws.rs | 189 ++ crates/alien-infra/src/ai/aws_import.rs | 31 + crates/alien-infra/src/ai/azure.rs | 998 ++++++++ crates/alien-infra/src/ai/azure_import.rs | 35 + crates/alien-infra/src/ai/gcp.rs | 335 +++ crates/alien-infra/src/ai/gcp_import.rs | 36 + crates/alien-infra/src/ai/local.rs | 203 ++ crates/alien-infra/src/ai/mod.rs | 32 + crates/alien-infra/src/aws_importers.rs | 4 +- crates/alien-infra/src/azure_importers.rs | 8 +- crates/alien-infra/src/container/local.rs | 53 +- crates/alien-infra/src/core/controller.rs | 10 + .../src/core/environment_variables.rs | 94 +- .../core/executor_tests/edge_cases_tests.rs | 59 +- crates/alien-infra/src/core/registry.rs | 28 +- .../alien-infra/src/core/service_provider.rs | 15 + crates/alien-infra/src/daemon/local.rs | 15 +- crates/alien-infra/src/gcp_importers.rs | 6 +- crates/alien-infra/src/lib.rs | 13 + crates/alien-infra/src/worker/local.rs | 15 +- crates/alien-infra/tests/importers.rs | 139 +- crates/alien-local/src/daemon_supervisor.rs | 19 +- crates/alien-local/src/lib.rs | 2 +- .../src/local_bindings_provider.rs | 116 +- crates/alien-local/src/worker_manager.rs | 149 +- .../permission-sets/ai/heartbeat.jsonc | 53 + .../permission-sets/ai/invoke.jsonc | 79 + .../permission-sets/ai/management.jsonc | 49 + .../permission-sets/ai/provision.jsonc | 69 + .../src/generators/azure_runtime.rs | 1 + .../tests/aws_abac_validation.rs | 27 +- .../tests/permission_set_validation.rs | 29 +- .../alien-permissions/tests/registry_tests.rs | 198 ++ .../compile_time/allowed_user_resources.rs | 1 + crates/alien-terraform/src/built_ins.rs | 9 +- crates/alien-terraform/src/emitters/aws/ai.rs | 119 + .../alien-terraform/src/emitters/aws/mod.rs | 2 + .../alien-terraform/src/emitters/azure/ai.rs | 233 ++ .../alien-terraform/src/emitters/azure/mod.rs | 2 + crates/alien-terraform/src/emitters/gcp/ai.rs | 39 + .../alien-terraform/src/emitters/gcp/mod.rs | 2 + .../tests/generator/aws_data_layer_tests.rs | 59 +- .../tests/generator/azure_data_layer_tests.rs | 82 +- .../tests/generator/gcp_data_layer_tests.rs | 57 +- ...r__generator__helpers__aws_ai_minimal.snap | 290 +++ ..._generator__helpers__azure_ai_minimal.snap | 398 ++++ ...r__generator__helpers__gcp_ai_minimal.snap | 302 +++ crates/alien-test/src/e2e.rs | 11 + crates/alien-test/tests/common/bindings.rs | 112 + crates/alien-test/tests/common/runner.rs | 1 + examples/ai-chatbot-ts/.dockerignore | 4 + examples/ai-chatbot-ts/.gitignore | 6 + examples/ai-chatbot-ts/Dockerfile | 19 + examples/ai-chatbot-ts/README.md | 46 + examples/ai-chatbot-ts/alien.ts | 40 + examples/ai-chatbot-ts/app/api/chat/route.ts | 86 + .../ai-chatbot-ts/app/api/models/route.ts | 8 + examples/ai-chatbot-ts/app/api/seed/route.ts | 85 + .../app/components/grain-background.tsx | 39 + .../ai-chatbot-ts/app/components/message.tsx | 61 + .../app/components/query-card.tsx | 123 + .../ai-chatbot-ts/app/components/spinner.tsx | 19 + examples/ai-chatbot-ts/app/globals.css | 16 + examples/ai-chatbot-ts/app/layout.tsx | 18 + examples/ai-chatbot-ts/app/page.tsx | 235 ++ examples/ai-chatbot-ts/next.config.ts | 8 + examples/ai-chatbot-ts/package.json | 39 + examples/ai-chatbot-ts/postcss.config.mjs | 5 + examples/ai-chatbot-ts/public/robots.txt | 2 + examples/ai-chatbot-ts/template.toml | 3 + examples/ai-chatbot-ts/tsconfig.json | 30 + examples/ai-quickstart-ts/README.md | 30 + examples/ai-quickstart-ts/alien.ts | 26 + examples/ai-quickstart-ts/package.json | 19 + examples/ai-quickstart-ts/src/index.ts | 30 + examples/ai-quickstart-ts/template.toml | 3 + examples/ai-quickstart-ts/tsconfig.json | 14 + examples/package.json | 3 +- examples/pnpm-lock.yaml | 2010 ++++++++++++++-- examples/pnpm-workspace.yaml | 2 + packages/ai-gateway/PACKAGE_LAYOUT.md | 77 + packages/ai-gateway/npm/.gitignore | 4 + .../ai-gateway/npm/darwin-arm64/package.json | 9 + .../ai-gateway/npm/darwin-x64/package.json | 9 + .../npm/linux-arm64-gnu/package.json | 9 + .../ai-gateway/npm/linux-x64-gnu/package.json | 9 + packages/ai-gateway/package.json | 64 + .../scripts/inject-optional-deps.mjs | 44 + .../scripts/validate-release-version.mjs | 38 + .../ai-gateway/src/__tests__/client.test.ts | 280 +++ .../ai-gateway/src/__tests__/gateway.test.ts | 57 + .../ai-gateway/src/alien-ai-gateway.d.node.ts | 9 + packages/ai-gateway/src/binding.ts | 76 + packages/ai-gateway/src/client.ts | 393 ++++ packages/ai-gateway/src/errors.ts | 86 + packages/ai-gateway/src/gateway.ts | 31 + packages/ai-gateway/src/index.ts | 48 + packages/ai-gateway/src/loader.ts | 205 ++ packages/ai-gateway/src/napi-error.ts | 79 + packages/ai-gateway/src/native.ts | 38 + packages/ai-gateway/tsconfig.build.json | 8 + packages/ai-gateway/tsconfig.json | 8 + packages/ai-gateway/tsdown.config.ts | 12 + packages/bindings/package.json | 26 +- .../bindings/src/__tests__/postgres.test.ts | 92 + packages/bindings/src/errors.ts | 21 + packages/bindings/src/index.ts | 13 +- packages/bindings/src/postgres.ts | 436 ++++ packages/core/src/__tests__/ai.test.ts | 29 + packages/core/src/ai.ts | 44 + packages/core/src/common-errors.ts | 30 + packages/core/src/generated/index.ts | 16 + packages/core/src/generated/schemas/ai.json | 1 + .../generated/schemas/aiHeartbeatData.json | 1 + .../generated/schemas/aiHeartbeatStatus.json | 1 + .../core/src/generated/schemas/aiOutputs.json | 1 + .../schemas/awsBedrockAiHeartbeatData.json | 1 + .../schemas/azureFoundryAiHeartbeatData.json | 1 + .../schemas/externalAiHeartbeatData.json | 1 + .../schemas/gcpVertexAiHeartbeatData.json | 1 + .../generated/schemas/resourceHeartbeat.json | 2 +- .../schemas/resourceHeartbeatData.json | 2 +- .../generated/zod/ai-heartbeat-data-schema.ts | 22 + .../zod/ai-heartbeat-status-schema.ts | 26 + .../src/generated/zod/ai-outputs-schema.ts | 17 + packages/core/src/generated/zod/ai-schema.ts | 15 + .../aws-bedrock-ai-heartbeat-data-schema.ts | 16 + .../zod/aws-network-import-data-schema.ts | 4 +- .../azure-foundry-ai-heartbeat-data-schema.ts | 19 + .../generated/zod/compute-cluster-schema.ts | 6 +- .../zod/external-ai-heartbeat-data-schema.ts | 16 + .../gcp-vertex-ai-heartbeat-data-schema.ts | 17 + packages/core/src/generated/zod/index.ts | 16 + .../zod/resource-heartbeat-data-schema.ts | 6 + packages/core/src/index.ts | 1 + packages/sdk/package.json | 3 +- packages/sdk/src/index.ts | 28 +- pnpm-lock.yaml | 312 +++ .../comprehensive-typescript/alien.ts | 4 + .../src/handlers/ai.ts | 65 + .../comprehensive-typescript/src/index.ts | 2 + 194 files changed, 16807 insertions(+), 471 deletions(-) create mode 100644 crates/alien-ai-gateway-node/.gitignore create mode 100644 crates/alien-ai-gateway-node/Cargo.toml create mode 100644 crates/alien-ai-gateway-node/build.rs create mode 100644 crates/alien-ai-gateway-node/package.json create mode 100644 crates/alien-ai-gateway-node/src/error.rs create mode 100644 crates/alien-ai-gateway-node/src/lib.rs create mode 100644 crates/alien-azure-clients/src/azure/cognitive_services.rs create mode 100644 crates/alien-cloudformation/src/emitters/aws/ai.rs create mode 100644 crates/alien-cloudformation/tests/generator/aws_ai_tests.rs create mode 100644 crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_ai_tests__aws_ai_invoke_permissions.snap create mode 100644 crates/alien-core/src/ai_catalog.rs create mode 100644 crates/alien-core/src/bindings/ai.rs create mode 100644 crates/alien-core/src/import/data/aws/ai.rs create mode 100644 crates/alien-core/src/import/data/azure/ai.rs create mode 100644 crates/alien-core/src/import/data/gcp/ai.rs create mode 100644 crates/alien-core/src/resources/ai.rs create mode 100644 crates/alien-gateway/Cargo.toml create mode 100644 crates/alien-gateway/src/bin/alien-ai-gateway.rs create mode 100644 crates/alien-gateway/src/config.rs create mode 100644 crates/alien-gateway/src/creds.rs create mode 100644 crates/alien-gateway/src/error.rs create mode 100644 crates/alien-gateway/src/lib.rs create mode 100644 crates/alien-gateway/src/router.rs create mode 100644 crates/alien-gateway/tests/integration.rs create mode 100644 crates/alien-gateway/tests/launcher.rs create mode 100644 crates/alien-gateway/tests/live_bedrock.rs create mode 100644 crates/alien-gateway/tests/live_foundry_claude.rs create mode 100644 crates/alien-gateway/tests/live_vertex_claude.rs create mode 100644 crates/alien-infra/src/ai/aws.rs create mode 100644 crates/alien-infra/src/ai/aws_import.rs create mode 100644 crates/alien-infra/src/ai/azure.rs create mode 100644 crates/alien-infra/src/ai/azure_import.rs create mode 100644 crates/alien-infra/src/ai/gcp.rs create mode 100644 crates/alien-infra/src/ai/gcp_import.rs create mode 100644 crates/alien-infra/src/ai/local.rs create mode 100644 crates/alien-infra/src/ai/mod.rs create mode 100644 crates/alien-permissions/permission-sets/ai/heartbeat.jsonc create mode 100644 crates/alien-permissions/permission-sets/ai/invoke.jsonc create mode 100644 crates/alien-permissions/permission-sets/ai/management.jsonc create mode 100644 crates/alien-permissions/permission-sets/ai/provision.jsonc create mode 100644 crates/alien-terraform/src/emitters/aws/ai.rs create mode 100644 crates/alien-terraform/src/emitters/azure/ai.rs create mode 100644 crates/alien-terraform/src/emitters/gcp/ai.rs create mode 100644 crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_ai_minimal.snap create mode 100644 crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__azure_ai_minimal.snap create mode 100644 crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__gcp_ai_minimal.snap create mode 100644 examples/ai-chatbot-ts/.dockerignore create mode 100644 examples/ai-chatbot-ts/.gitignore create mode 100644 examples/ai-chatbot-ts/Dockerfile create mode 100644 examples/ai-chatbot-ts/README.md create mode 100644 examples/ai-chatbot-ts/alien.ts create mode 100644 examples/ai-chatbot-ts/app/api/chat/route.ts create mode 100644 examples/ai-chatbot-ts/app/api/models/route.ts create mode 100644 examples/ai-chatbot-ts/app/api/seed/route.ts create mode 100644 examples/ai-chatbot-ts/app/components/grain-background.tsx create mode 100644 examples/ai-chatbot-ts/app/components/message.tsx create mode 100644 examples/ai-chatbot-ts/app/components/query-card.tsx create mode 100644 examples/ai-chatbot-ts/app/components/spinner.tsx create mode 100644 examples/ai-chatbot-ts/app/globals.css create mode 100644 examples/ai-chatbot-ts/app/layout.tsx create mode 100644 examples/ai-chatbot-ts/app/page.tsx create mode 100644 examples/ai-chatbot-ts/next.config.ts create mode 100644 examples/ai-chatbot-ts/package.json create mode 100644 examples/ai-chatbot-ts/postcss.config.mjs create mode 100644 examples/ai-chatbot-ts/public/robots.txt create mode 100644 examples/ai-chatbot-ts/template.toml create mode 100644 examples/ai-chatbot-ts/tsconfig.json create mode 100644 examples/ai-quickstart-ts/README.md create mode 100644 examples/ai-quickstart-ts/alien.ts create mode 100644 examples/ai-quickstart-ts/package.json create mode 100644 examples/ai-quickstart-ts/src/index.ts create mode 100644 examples/ai-quickstart-ts/template.toml create mode 100644 examples/ai-quickstart-ts/tsconfig.json create mode 100644 packages/ai-gateway/PACKAGE_LAYOUT.md create mode 100644 packages/ai-gateway/npm/.gitignore create mode 100644 packages/ai-gateway/npm/darwin-arm64/package.json create mode 100644 packages/ai-gateway/npm/darwin-x64/package.json create mode 100644 packages/ai-gateway/npm/linux-arm64-gnu/package.json create mode 100644 packages/ai-gateway/npm/linux-x64-gnu/package.json create mode 100644 packages/ai-gateway/package.json create mode 100644 packages/ai-gateway/scripts/inject-optional-deps.mjs create mode 100644 packages/ai-gateway/scripts/validate-release-version.mjs create mode 100644 packages/ai-gateway/src/__tests__/client.test.ts create mode 100644 packages/ai-gateway/src/__tests__/gateway.test.ts create mode 100644 packages/ai-gateway/src/alien-ai-gateway.d.node.ts create mode 100644 packages/ai-gateway/src/binding.ts create mode 100644 packages/ai-gateway/src/client.ts create mode 100644 packages/ai-gateway/src/errors.ts create mode 100644 packages/ai-gateway/src/gateway.ts create mode 100644 packages/ai-gateway/src/index.ts create mode 100644 packages/ai-gateway/src/loader.ts create mode 100644 packages/ai-gateway/src/napi-error.ts create mode 100644 packages/ai-gateway/src/native.ts create mode 100644 packages/ai-gateway/tsconfig.build.json create mode 100644 packages/ai-gateway/tsconfig.json create mode 100644 packages/ai-gateway/tsdown.config.ts create mode 100644 packages/bindings/src/__tests__/postgres.test.ts create mode 100644 packages/bindings/src/postgres.ts create mode 100644 packages/core/src/__tests__/ai.test.ts create mode 100644 packages/core/src/ai.ts create mode 100644 packages/core/src/generated/schemas/ai.json create mode 100644 packages/core/src/generated/schemas/aiHeartbeatData.json create mode 100644 packages/core/src/generated/schemas/aiHeartbeatStatus.json create mode 100644 packages/core/src/generated/schemas/aiOutputs.json create mode 100644 packages/core/src/generated/schemas/awsBedrockAiHeartbeatData.json create mode 100644 packages/core/src/generated/schemas/azureFoundryAiHeartbeatData.json create mode 100644 packages/core/src/generated/schemas/externalAiHeartbeatData.json create mode 100644 packages/core/src/generated/schemas/gcpVertexAiHeartbeatData.json create mode 100644 packages/core/src/generated/zod/ai-heartbeat-data-schema.ts create mode 100644 packages/core/src/generated/zod/ai-heartbeat-status-schema.ts create mode 100644 packages/core/src/generated/zod/ai-outputs-schema.ts create mode 100644 packages/core/src/generated/zod/ai-schema.ts create mode 100644 packages/core/src/generated/zod/aws-bedrock-ai-heartbeat-data-schema.ts create mode 100644 packages/core/src/generated/zod/azure-foundry-ai-heartbeat-data-schema.ts create mode 100644 packages/core/src/generated/zod/external-ai-heartbeat-data-schema.ts create mode 100644 packages/core/src/generated/zod/gcp-vertex-ai-heartbeat-data-schema.ts create mode 100644 tests/e2e/test-apps/comprehensive-typescript/src/handlers/ai.ts diff --git a/.github/workflows/ci-fast.yml b/.github/workflows/ci-fast.yml index 0cd515dd8..d069497af 100644 --- a/.github/workflows/ci-fast.yml +++ b/.github/workflows/ci-fast.yml @@ -155,13 +155,14 @@ jobs: - name: TypeScript unit tests # The per-package vitest suites (commands receiver twins, bindings - # loader/error mapping, core schemas) — `test:ts` above is - # typecheck-only, so without this step they never ran in CI. Must run - # after the dev-addon build: the bindings kv/queue/storage/vault - # suites load the native addon. + # loader/error mapping, core schemas, ai-gateway client/gateway) — + # `test:ts` above is typecheck-only, so without this step they never + # ran in CI. Must run after the dev-addon build: the bindings + # kv/queue/storage/vault suites load the native addon. The ai-gateway + # suites inject a mock addon, so they need no built .node here. env: NODE_OPTIONS: "--max-old-space-size=8192" - run: pnpm --filter @alienplatform/core --filter @alienplatform/bindings --filter @alienplatform/commands test + run: pnpm --filter @alienplatform/core --filter @alienplatform/bindings --filter @alienplatform/commands --filter @alienplatform/ai-gateway test - name: Rust fast tests (non-cloud) env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fb32e198..356049085 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,7 +106,10 @@ jobs: client-sdks/platform/typescript client-sdks/manager/typescript \ packages/bindings crates/alien-bindings-node \ packages/bindings/npm/darwin-arm64 packages/bindings/npm/darwin-x64 \ - packages/bindings/npm/linux-x64-gnu packages/bindings/npm/linux-arm64-gnu; do + packages/bindings/npm/linux-x64-gnu packages/bindings/npm/linux-arm64-gnu \ + packages/ai-gateway crates/alien-ai-gateway-node \ + packages/ai-gateway/npm/darwin-arm64 packages/ai-gateway/npm/darwin-x64 \ + packages/ai-gateway/npm/linux-x64-gnu packages/ai-gateway/npm/linux-arm64-gnu; do node -e " const fs = require('fs'); const path = '${pkg}/package.json'; @@ -151,7 +154,10 @@ jobs: client-sdks/platform/typescript/package.json client-sdks/manager/typescript/package.json \ packages/bindings/package.json crates/alien-bindings-node/package.json \ packages/bindings/npm/darwin-arm64/package.json packages/bindings/npm/darwin-x64/package.json \ - packages/bindings/npm/linux-x64-gnu/package.json packages/bindings/npm/linux-arm64-gnu/package.json + packages/bindings/npm/linux-x64-gnu/package.json packages/bindings/npm/linux-arm64-gnu/package.json \ + packages/ai-gateway/package.json crates/alien-ai-gateway-node/package.json \ + packages/ai-gateway/npm/darwin-arm64/package.json packages/ai-gateway/npm/darwin-x64/package.json \ + packages/ai-gateway/npm/linux-x64-gnu/package.json packages/ai-gateway/npm/linux-arm64-gnu/package.json git commit -m "chore: release v${VERSION}" git tag "v${VERSION}" git push @@ -397,6 +403,11 @@ jobs: VERSION: ${{ needs.prepare.outputs.version }} run: node packages/bindings/scripts/validate-release-version.mjs "$VERSION" + - name: Validate ai-gateway release version lockstep + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: node packages/ai-gateway/scripts/validate-release-version.mjs "$VERSION" + # Rust toolchain: Linux via the shared composite; macOS uses dtolnay + # brew protoc directly (plain `napi build`, not `depot cargo`, so no # Depot/sccache setup here). The addon depends on alien-bindings, whose @@ -460,6 +471,31 @@ jobs: retention-days: 1 path: packages/bindings/npm/${{ matrix.triple }}/alien-bindings-node.${{ matrix.triple }}.node + # The AI gateway addon reuses this matrix (same runners/toolchain) — it also + # depends on alien-bindings' credential resolver, so protoc is already set up. + - name: Build ai-gateway addon (${{ matrix.triple }}) + env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + run: | + pnpm --filter @alienplatform/ai-gateway exec napi build \ + --platform --release --target ${{ matrix.target }} \ + --cwd "$GITHUB_WORKSPACE/crates/alien-ai-gateway-node" + + - name: Stage ai-gateway addon into platform package + run: | + cp "crates/alien-ai-gateway-node/alien-ai-gateway-node.${{ matrix.triple }}.node" \ + "packages/ai-gateway/npm/${{ matrix.triple }}/alien-ai-gateway-node.${{ matrix.triple }}.node" + + - name: Assert staged ai-gateway binary architecture + run: file "packages/ai-gateway/npm/${{ matrix.triple }}/alien-ai-gateway-node.${{ matrix.triple }}.node" | grep -q "${{ matrix.expect_arch }}" + + - name: Upload ai-gateway addon artifact + uses: actions/upload-artifact@v4 + with: + name: ai-gateway-addon-${{ matrix.triple }} + retention-days: 1 + path: packages/ai-gateway/npm/${{ matrix.triple }}/alien-ai-gateway-node.${{ matrix.triple }}.node + # This job deliberately has no Rust compiler or protoc setup. It # starts from the already-built artifacts and proves that a consumer whose # only direct dependency is @alienplatform/bindings gets the right optional @@ -503,6 +539,11 @@ jobs: VERSION: ${{ needs.prepare.outputs.version }} run: node packages/bindings/scripts/validate-release-version.mjs "$VERSION" + - name: Validate ai-gateway release version lockstep + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: node packages/ai-gateway/scripts/validate-release-version.mjs "$VERSION" + - uses: pnpm/action-setup@v6 with: version: 10.11.0 @@ -561,6 +602,11 @@ jobs: VERSION: ${{ needs.prepare.outputs.version }} run: node packages/bindings/scripts/validate-release-version.mjs "$VERSION" + - name: Validate ai-gateway release version lockstep + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: node packages/ai-gateway/scripts/validate-release-version.mjs "$VERSION" + - uses: pnpm/action-setup@v6 with: version: 10.11.0 @@ -645,6 +691,76 @@ jobs: pnpm --filter @alienplatform/bindings publish --access public --no-git-checks fi + publish-ai-gateway: + needs: [prepare, build-addon, publish-npm] + # Same ordering rules as publish-bindings: the wrapper pins + # `@alienplatform/core: ^`, so it publishes only after core, and + # its per-platform prebuilds publish before the wrapper that pins them. + if: always() && needs.build-addon.result == 'success' && (inputs.dry_run || needs.publish-npm.result == 'success') + runs-on: depot-ubuntu-24.04-arm-4 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.dry_run && github.sha || format('v{0}', needs.prepare.outputs.version) }} + + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: Validate npm auth + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm whoami + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download ai-gateway addon artifacts + uses: actions/download-artifact@v4 + with: + pattern: ai-gateway-addon-* + path: ./addons + merge-multiple: false + + - name: Stage addons into platform packages + run: | + for triple in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu; do + cp "./addons/ai-gateway-addon-${triple}/alien-ai-gateway-node.${triple}.node" \ + "packages/ai-gateway/npm/${triple}/alien-ai-gateway-node.${triple}.node" + done + + - name: Build wrapper + run: pnpm --filter @alienplatform/ai-gateway build + + - name: Publish platform prebuild packages (first) + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + DRY="" + if [ "${{ inputs.dry_run }}" = "true" ]; then DRY="--dry-run"; fi + for triple in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu; do + echo "Publishing @alienplatform/ai-gateway-${triple}..." + ( cd "packages/ai-gateway/npm/${triple}" && npm publish --access public $DRY ) + done + + - name: Inject optionalDependencies into the wrapper + run: node packages/ai-gateway/scripts/inject-optional-deps.mjs + + - name: Publish wrapper (last) + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + DRY="" + if [ "${{ inputs.dry_run }}" = "true" ]; then DRY="--dry-run"; fi + pnpm --filter @alienplatform/ai-gateway publish --access public --no-git-checks $DRY + # ─── Binary builds (4 targets, all parallel) ─────────────────────── build-binaries-linux-x86_64: diff --git a/Cargo.lock b/Cargo.lock index 823832b2a..3af4444f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,9 +83,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "alien-ai-gateway-node" +version = "2.1.6" +dependencies = [ + "alien-error", + "alien-gateway", + "napi", + "napi-build", + "napi-derive", + "serde", + "serde_json", +] + [[package]] name = "alien-aws-clients" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-aws-clients", "alien-client-core", @@ -127,7 +140,7 @@ dependencies = [ [[package]] name = "alien-azure-clients" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-client-core", "alien-core", @@ -171,7 +184,7 @@ dependencies = [ [[package]] name = "alien-bindings" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-aws-clients", "alien-azure-clients", @@ -215,7 +228,7 @@ dependencies = [ [[package]] name = "alien-bindings-node" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-bindings", "alien-error", @@ -232,7 +245,7 @@ dependencies = [ [[package]] name = "alien-build" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-build", "alien-core", @@ -266,7 +279,7 @@ dependencies = [ [[package]] name = "alien-cli" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-bindings", "alien-build", @@ -337,7 +350,7 @@ dependencies = [ [[package]] name = "alien-cli-common" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-core", "alien-deployment", @@ -351,7 +364,7 @@ dependencies = [ [[package]] name = "alien-client-config" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-aws-clients", "alien-azure-clients", @@ -369,7 +382,7 @@ dependencies = [ [[package]] name = "alien-client-core" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-error", "anyhow", @@ -388,7 +401,7 @@ dependencies = [ [[package]] name = "alien-cloudformation" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-core", "alien-error", @@ -403,7 +416,7 @@ dependencies = [ [[package]] name = "alien-commands" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-aws-clients", "alien-azure-clients", @@ -441,7 +454,7 @@ dependencies = [ [[package]] name = "alien-commands-client" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-core", "base64 0.22.1", @@ -456,7 +469,7 @@ dependencies = [ [[package]] name = "alien-core" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-error", "alien-macros", @@ -488,7 +501,7 @@ dependencies = [ [[package]] name = "alien-deploy-cli" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-cli-common", "alien-core", @@ -531,7 +544,7 @@ dependencies = [ [[package]] name = "alien-deployment" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-aws-clients", "alien-azure-clients", @@ -562,7 +575,7 @@ dependencies = [ [[package]] name = "alien-error" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-error-derive", "anyhow", @@ -575,7 +588,7 @@ dependencies = [ [[package]] name = "alien-error-derive" -version = "2.0.2" +version = "2.1.6" dependencies = [ "proc-macro2", "quote", @@ -583,9 +596,34 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "alien-gateway" +version = "2.1.6" +dependencies = [ + "alien-bindings", + "alien-core", + "alien-error", + "aws-config", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-eventstream", + "aws-smithy-types", + "axum 0.8.9", + "base64 0.22.1", + "futures", + "http 1.4.2", + "httpmock", + "reqwest 0.12.28", + "serde", + "serde_json", + "temp-env", + "tokio", + "tracing", +] + [[package]] name = "alien-gcp-clients" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-client-core", "alien-core", @@ -618,7 +656,7 @@ dependencies = [ [[package]] name = "alien-helm" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-core", "alien-error", @@ -632,7 +670,7 @@ dependencies = [ [[package]] name = "alien-infra" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-aws-clients", "alien-azure-clients", @@ -692,7 +730,7 @@ dependencies = [ [[package]] name = "alien-k8s-clients" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-client-core", "alien-core", @@ -721,7 +759,7 @@ dependencies = [ [[package]] name = "alien-local" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-bindings", "alien-build", @@ -764,7 +802,7 @@ dependencies = [ [[package]] name = "alien-macros" -version = "2.0.2" +version = "2.1.6" dependencies = [ "proc-macro2", "quote", @@ -773,7 +811,7 @@ dependencies = [ [[package]] name = "alien-manager" -version = "2.0.2" +version = "2.1.6" dependencies = [ "aegis", "alien-azure-clients", @@ -834,7 +872,7 @@ dependencies = [ [[package]] name = "alien-manager-api" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-error", "chrono", @@ -853,7 +891,7 @@ dependencies = [ [[package]] name = "alien-observer" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-aws-clients", "alien-azure-clients", @@ -873,7 +911,7 @@ dependencies = [ [[package]] name = "alien-operator" -version = "2.0.2" +version = "2.1.6" dependencies = [ "aegis", "alien-client-config", @@ -917,7 +955,7 @@ dependencies = [ [[package]] name = "alien-permissions" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-core", "alien-error", @@ -936,7 +974,7 @@ dependencies = [ [[package]] name = "alien-platform-api" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-error", "chrono", @@ -955,7 +993,7 @@ dependencies = [ [[package]] name = "alien-preflights" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-core", "alien-error", @@ -972,7 +1010,7 @@ dependencies = [ [[package]] name = "alien-sdk" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-bindings", "alien-core", @@ -994,7 +1032,7 @@ dependencies = [ [[package]] name = "alien-terraform" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-core", "alien-error", @@ -1009,7 +1047,7 @@ dependencies = [ [[package]] name = "alien-test" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-aws-clients", "alien-azure-clients", @@ -1056,7 +1094,7 @@ dependencies = [ [[package]] name = "alien-test-app" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-error", "alien-sdk", @@ -1102,7 +1140,7 @@ dependencies = [ [[package]] name = "alien-worker-protocol" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-error", "async-stream", @@ -1121,7 +1159,7 @@ dependencies = [ [[package]] name = "alien-worker-runtime" -version = "2.0.2" +version = "2.1.6" dependencies = [ "alien-bindings", "alien-commands", @@ -1831,6 +1869,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78d8391e65fcea47c586a22e1a41f173b38615b112b2c6b7a44e80cec3e6b706" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + [[package]] name = "aws-smithy-http" version = "0.63.6" diff --git a/Cargo.toml b/Cargo.toml index ba71eca1e..43fde8cda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ members = [ "crates/alien-k8s-clients", "crates/alien-observer", "crates/alien-error", + "crates/alien-gateway", + "crates/alien-ai-gateway-node", "crates/alien-error-derive", "crates/alien-permissions", "crates/alien-preflights", @@ -48,6 +50,8 @@ resolver = "2" default-members = [ "crates/alien-core", "crates/alien-error", + "crates/alien-gateway", + "crates/alien-ai-gateway-node", "crates/alien-macros" ] @@ -64,6 +68,7 @@ license-file = "LICENSE.md" alien-commands = { path = "crates/alien-commands", version = "2.1.6", default-features = false } alien-commands-client = { path = "crates/alien-commands-client" } alien-bindings = { path = "crates/alien-bindings", version = "2.1.6", default-features = false } +alien-gateway = { path = "crates/alien-gateway", version = "2.1.6" } alien-worker-protocol = { path = "crates/alien-worker-protocol", version = "2.1.6" } alien-sdk = { path = "crates/alien-sdk", version = "2.1.6", default-features = false } alien-worker-runtime = { path = "crates/alien-worker-runtime", version = "2.1.6" } @@ -98,6 +103,7 @@ alien-platform-api = { path = "client-sdks/platform/rust", version = "2.1.6", de anyhow = "1.0" async-stream = "0.3" async-trait = "0.1.88" +aws-config = "1.5" aws-credential-types = "1.2" aws_lambda_events = { version = "0.16", features = ["cloudwatch_events", "s3", "sqs"] } aws-sigv4 = "1.3.2" @@ -209,6 +215,7 @@ wasm-bindgen-futures = "0.4.40" wasm-bindgen-test = "0.3.0" console_error_panic_hook = "0.1.7" aws-smithy-async = "1.2.5" +aws-smithy-eventstream = "0.60" aws-smithy-runtime-api = "1.8" aws-smithy-types = "1.3" getrandom = { version = "0.3", features = ["wasm_js"] } diff --git a/crates/alien-ai-gateway-node/.gitignore b/crates/alien-ai-gateway-node/.gitignore new file mode 100644 index 000000000..2f6761404 --- /dev/null +++ b/crates/alien-ai-gateway-node/.gitignore @@ -0,0 +1,10 @@ +# Prebuilt native binaries — produced by `napi build`, assembled into per-platform +# npm packages at build time; never committed. +*.node +node_modules/ +# napi-rs generates these beside the native translation crate, but the public +# loader and declarations live in packages/ai-gateway. Keep the generated shim +# out of the repository so it cannot become a second public surface. +index.js +index.d.ts +pnpm-lock.yaml diff --git a/crates/alien-ai-gateway-node/Cargo.toml b/crates/alien-ai-gateway-node/Cargo.toml new file mode 100644 index 000000000..8e30c33b1 --- /dev/null +++ b/crates/alien-ai-gateway-node/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "alien-ai-gateway-node" +version.workspace = true +edition = "2021" +description = "Node-API (napi-rs) addon exposing the alien AI gateway to JavaScript" +license-file.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +alien-gateway = { workspace = true } +alien-error = { workspace = true } +napi = { workspace = true } +napi-derive = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[build-dependencies] +napi-build = { workspace = true } diff --git a/crates/alien-ai-gateway-node/build.rs b/crates/alien-ai-gateway-node/build.rs new file mode 100644 index 000000000..e8ebbfcb6 --- /dev/null +++ b/crates/alien-ai-gateway-node/build.rs @@ -0,0 +1,18 @@ +fn main() { + napi_build::setup(); + + // `cargo test` links a plain executable with no Node/Bun host, so the `napi_*` + // symbols the cdylib resolves at load time are undefined at link time. Defer + // resolution for every locally-linked artifact — the mapper tests never call + // into napi. + match std::env::var("CARGO_CFG_TARGET_OS").as_deref() { + Ok("macos") => { + println!("cargo:rustc-link-arg=-undefined"); + println!("cargo:rustc-link-arg=dynamic_lookup"); + } + Ok("linux") => { + println!("cargo:rustc-link-arg=-Wl,--unresolved-symbols=ignore-all"); + } + _ => {} + } +} diff --git a/crates/alien-ai-gateway-node/package.json b/crates/alien-ai-gateway-node/package.json new file mode 100644 index 000000000..8ef6895a7 --- /dev/null +++ b/crates/alien-ai-gateway-node/package.json @@ -0,0 +1,19 @@ +{ + "name": "@alienplatform/ai-gateway-node", + "version": "0.0.0", + "private": true, + "description": "napi-rs addon exposing the alien AI gateway to JavaScript", + "napi": { + "binaryName": "alien-ai-gateway-node", + "packageName": "@alienplatform/ai-gateway", + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu" + ] + }, + "devDependencies": { + "@napi-rs/cli": "^3.0.0" + } +} diff --git a/crates/alien-ai-gateway-node/src/error.rs b/crates/alien-ai-gateway-node/src/error.rs new file mode 100644 index 000000000..eed701fb8 --- /dev/null +++ b/crates/alien-ai-gateway-node/src/error.rs @@ -0,0 +1,37 @@ +//! Error translation from `alien-gateway` into `napi::Error`. +//! +//! Mirrors the envelope contract used by `alien-bindings-node`: async `#[napi]` +//! methods can only carry data to JS through the error `reason` (napi's `Status` +//! is a closed enum), so a stable JSON envelope `{ code, message, context?, +//! retryable, internal }` is serialized into `reason` and the TS layer recovers +//! it with a single `JSON.parse`. `internal` crosses the boundary so the JS side +//! can honor the Rust error's redaction posture rather than defaulting it open. + +use alien_error::AlienError; +use alien_gateway::ErrorData; +use serde::Serialize; + +#[derive(Debug, Serialize)] +struct ErrorEnvelope<'a> { + code: &'a str, + message: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option<&'a serde_json::Value>, + retryable: bool, + internal: bool, +} + +/// Serialize an `AlienError` into a `napi::Error` carrying the structured envelope +/// in its `reason`. The napi `Status` is always `GenericFailure`; the real code +/// lives in the envelope. +pub fn map_gateway_error(err: AlienError) -> napi::Error { + let envelope = ErrorEnvelope { + code: &err.code, + message: &err.message, + context: err.context.as_ref(), + retryable: err.retryable, + internal: err.internal, + }; + let reason = serde_json::to_string(&envelope).unwrap_or_else(|_| err.message.clone()); + napi::Error::new(napi::Status::GenericFailure, reason) +} diff --git a/crates/alien-ai-gateway-node/src/lib.rs b/crates/alien-ai-gateway-node/src/lib.rs new file mode 100644 index 000000000..f10378057 --- /dev/null +++ b/crates/alien-ai-gateway-node/src/lib.rs @@ -0,0 +1,52 @@ +//! Node-API addon for the alien AI gateway. +//! +//! The gateway itself is the Rust `alien-gateway` crate — the single, shared +//! implementation. This addon is a thin wrapper: it starts the gateway's loopback +//! HTTP server inside napi's tokio runtime and returns the server's URL to JS. +//! The application then points a plain OpenAI-compatible client at that URL, and +//! every request/response (including SSE token streams) flows over the loopback +//! HTTP socket — never across the napi boundary. + +use alien_gateway::{bindings_from_env, start_gateway, GatewayHandle}; +use napi_derive::napi; + +mod error; +use error::map_gateway_error; + +#[napi] +pub fn version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +/// A running gateway: its loopback base URL and the underlying handle, which owns +/// the server task. JS must hold this object for as long as it uses the gateway — +/// dropping it aborts the server. +#[napi] +pub struct AiGatewayHandle { + url: String, + _handle: GatewayHandle, +} + +#[napi] +impl AiGatewayHandle { + /// The gateway's loopback base URL (`http://127.0.0.1:`). Callers append + /// `//v1` and point an OpenAI-compatible client at it. + #[napi(getter)] + pub fn url(&self) -> String { + self.url.clone() + } +} + +/// Start the embedded AI gateway from the ambient `ALIEN__BINDING` env vars +/// in the current process, and return a handle whose `url` is the loopback base. +/// A no-op-cost call when the process links no ambient AI resource (the gateway +/// serves no routes). Hold the returned handle for the process lifetime. +#[napi] +pub async fn start_ai_gateway() -> napi::Result { + let bindings = bindings_from_env().map_err(map_gateway_error)?; + let handle = start_gateway(bindings).await.map_err(map_gateway_error)?; + Ok(AiGatewayHandle { + url: handle.url.clone(), + _handle: handle, + }) +} diff --git a/crates/alien-azure-clients/src/azure/cognitive_services.rs b/crates/alien-azure-clients/src/azure/cognitive_services.rs new file mode 100644 index 000000000..971ee964d --- /dev/null +++ b/crates/alien-azure-clients/src/azure/cognitive_services.rs @@ -0,0 +1,666 @@ +use crate::azure::common::{AzureClientBase, AzureRequestBuilder}; +use crate::azure::long_running_operation::OperationResult; +use crate::azure::token_cache::AzureTokenCache; +use alien_client_core::{ErrorData, Result}; +use alien_error::{Context, IntoAlienError}; +use reqwest::{Client, Method}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "test-utils")] +use mockall::automock; + +const COGNITIVE_SERVICES_API_VERSION: &str = "2024-10-01"; + +// ------------------------------------------------------------------------- +// ARM resource models +// ------------------------------------------------------------------------- + +/// Properties of a CognitiveServices account +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CognitiveServicesAccountProperties { + /// The endpoint URL of the account once provisioned + pub endpoint: Option, + /// The provisioning state of the account + pub provisioning_state: Option, +} + +/// SKU for a CognitiveServices account +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CognitiveServicesSku { + /// The SKU name (e.g. "S0") + pub name: String, +} + +/// An Azure CognitiveServices account resource +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CognitiveServicesAccount { + /// The ARM resource kind (e.g. "AIServices") + pub kind: Option, + /// The Azure region where the account lives + pub location: Option, + /// The SKU + pub sku: Option, + /// The account properties, including endpoint and provisioning state + pub properties: Option, +} + +/// Request body for creating a CognitiveServices account +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CognitiveServicesAccountCreateParameters { + /// Azure region + pub location: String, + /// Account kind — must be "AIServices" for Azure AI Services + pub kind: String, + /// SKU + pub sku: CognitiveServicesSku, + /// Account properties + pub properties: CognitiveServicesAccountCreateProperties, +} + +/// Properties included in the create request body +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CognitiveServicesAccountCreateProperties { + /// Custom subdomain name used to build the endpoint URL + pub custom_sub_domain_name: String, +} + +/// Create may complete synchronously or return an Azure long-running operation. +pub type CognitiveServicesAccountOperationResult = OperationResult; + +/// The model a deployment serves. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CognitiveServicesDeploymentModel { + /// Model format (e.g. "OpenAI") + pub format: String, + /// Model name (e.g. "gpt-4.1") + pub name: String, + /// Model version (e.g. "2025-04-14") + pub version: String, +} + +/// SKU (throughput tier + capacity) of a deployment. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CognitiveServicesDeploymentSku { + /// SKU name (e.g. "GlobalStandard") + pub name: String, + /// Provisioned throughput units + pub capacity: i32, +} + +/// Properties of a deployment. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CognitiveServicesDeploymentProperties { + /// The deployed model + pub model: CognitiveServicesDeploymentModel, + /// The provisioning state (response only) + pub provisioning_state: Option, +} + +/// A CognitiveServices model deployment resource. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CognitiveServicesDeployment { + /// The SKU + pub sku: Option, + /// The deployment properties, including the model and provisioning state + pub properties: Option, +} + +/// Properties included in the deployment create request body. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CognitiveServicesDeploymentCreateProperties { + /// The model to deploy + pub model: CognitiveServicesDeploymentModel, +} + +/// Request body for creating a deployment (PUT). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CognitiveServicesDeploymentCreateParameters { + /// The SKU (throughput tier + capacity) + pub sku: CognitiveServicesDeploymentSku, + /// The deployment properties + pub properties: CognitiveServicesDeploymentCreateProperties, +} + +/// Deployment create may complete synchronously or return a long-running operation. +pub type CognitiveServicesDeploymentOperationResult = OperationResult; + +// ------------------------------------------------------------------------- +// CognitiveServices Accounts API trait +// ------------------------------------------------------------------------- + +#[cfg_attr(feature = "test-utils", automock)] +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +pub trait CognitiveServicesAccountsApi: Send + Sync + std::fmt::Debug { + /// Create a CognitiveServices account (PUT). May return a long-running operation. + async fn create_account( + &self, + resource_group_name: &str, + account_name: &str, + parameters: &CognitiveServicesAccountCreateParameters, + ) -> Result; + + /// Get the properties of a CognitiveServices account. + async fn get_account( + &self, + resource_group_name: &str, + account_name: &str, + ) -> Result; + + /// Delete a CognitiveServices account. + async fn delete_account( + &self, + resource_group_name: &str, + account_name: &str, + ) -> Result<()>; + + /// Create (or update) a model deployment under an account (PUT). May return a + /// long-running operation. + async fn create_deployment( + &self, + resource_group_name: &str, + account_name: &str, + deployment_name: &str, + parameters: &CognitiveServicesDeploymentCreateParameters, + ) -> Result; + + /// Get a model deployment's properties, including its provisioning state. + async fn get_deployment( + &self, + resource_group_name: &str, + account_name: &str, + deployment_name: &str, + ) -> Result; +} + +// ------------------------------------------------------------------------- +// CognitiveServices Accounts client struct +// ------------------------------------------------------------------------- + +#[derive(Debug)] +pub struct AzureCognitiveServicesClient { + pub base: AzureClientBase, + pub token_cache: AzureTokenCache, +} + +impl AzureCognitiveServicesClient { + pub fn new(client: Client, token_cache: AzureTokenCache) -> Self { + let endpoint = token_cache.management_endpoint().to_string(); + Self { + base: AzureClientBase::with_client_config( + client, + endpoint, + token_cache.config().clone(), + ), + token_cache, + } + } + + fn resource_url(&self, resource_group_name: &str, account_name: &str) -> String { + format!( + "/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}", + self.token_cache.config().subscription_id, + resource_group_name, + account_name + ) + } + + fn deployment_url( + &self, + resource_group_name: &str, + account_name: &str, + deployment_name: &str, + ) -> String { + format!( + "{}/deployments/{}", + self.resource_url(resource_group_name, account_name), + deployment_name + ) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +impl CognitiveServicesAccountsApi for AzureCognitiveServicesClient { + /// Create a CognitiveServices account + async fn create_account( + &self, + resource_group_name: &str, + account_name: &str, + parameters: &CognitiveServicesAccountCreateParameters, + ) -> Result { + let bearer_token = self + .token_cache + .get_bearer_token_with_scope("https://management.azure.com/.default") + .await?; + + let url = self.base.build_url( + &self.resource_url(resource_group_name, account_name), + Some(vec![("api-version", COGNITIVE_SERVICES_API_VERSION.into())]), + ); + + let body = serde_json::to_string(parameters) + .into_alien_error() + .context(ErrorData::SerializationError { + message: format!( + "Failed to serialize CognitiveServices account create parameters for resource: {}", + account_name + ), + })?; + + let builder = AzureRequestBuilder::new(Method::PUT, url) + .content_type_json() + .content_length(&body) + .body(body); + + let req = builder.build()?; + let signed = self.base.sign_request(req, &bearer_token).await?; + + self.base + .execute_request_with_long_running_support(signed, "CreateCognitiveServicesAccount", account_name) + .await + } + + /// Get a CognitiveServices account's properties + async fn get_account( + &self, + resource_group_name: &str, + account_name: &str, + ) -> Result { + let bearer_token = self + .token_cache + .get_bearer_token_with_scope("https://management.azure.com/.default") + .await?; + + let url = self.base.build_url( + &self.resource_url(resource_group_name, account_name), + Some(vec![("api-version", COGNITIVE_SERVICES_API_VERSION.into())]), + ); + + let builder = AzureRequestBuilder::new(Method::GET, url.clone()).content_length(""); + + let req = builder.build()?; + let signed = self.base.sign_request(req, &bearer_token).await?; + let resp = self + .base + .execute_request(signed, "GetCognitiveServicesAccount", account_name) + .await?; + + let body = resp + .text() + .await + .into_alien_error() + .context(ErrorData::HttpResponseError { + message: format!( + "Azure GetCognitiveServicesAccount: failed to read response body for {}", + account_name + ), + url: url.clone(), + http_status: 200, + http_request_text: None, + http_response_text: None, + })?; + + let account: CognitiveServicesAccount = serde_json::from_str(&body) + .into_alien_error() + .context(ErrorData::HttpResponseError { + message: format!( + "Azure GetCognitiveServicesAccount: JSON parse error for {}", + account_name + ), + url: url.clone(), + http_status: 200, + http_request_text: None, + http_response_text: Some(body), + })?; + + Ok(account) + } + + /// Delete a CognitiveServices account + async fn delete_account( + &self, + resource_group_name: &str, + account_name: &str, + ) -> Result<()> { + let bearer_token = self + .token_cache + .get_bearer_token_with_scope("https://management.azure.com/.default") + .await?; + + let url = self.base.build_url( + &self.resource_url(resource_group_name, account_name), + Some(vec![("api-version", COGNITIVE_SERVICES_API_VERSION.into())]), + ); + + let builder = AzureRequestBuilder::new(Method::DELETE, url).content_length(""); + + let req = builder.build()?; + let signed = self.base.sign_request(req, &bearer_token).await?; + let _resp = self + .base + .execute_request(signed, "DeleteCognitiveServicesAccount", account_name) + .await?; + + Ok(()) + } + + /// Create (or update) a model deployment under an account + async fn create_deployment( + &self, + resource_group_name: &str, + account_name: &str, + deployment_name: &str, + parameters: &CognitiveServicesDeploymentCreateParameters, + ) -> Result { + let bearer_token = self + .token_cache + .get_bearer_token_with_scope("https://management.azure.com/.default") + .await?; + + let url = self.base.build_url( + &self.deployment_url(resource_group_name, account_name, deployment_name), + Some(vec![("api-version", COGNITIVE_SERVICES_API_VERSION.into())]), + ); + + let body = serde_json::to_string(parameters) + .into_alien_error() + .context(ErrorData::SerializationError { + message: format!( + "Failed to serialize CognitiveServices deployment create parameters for: {}", + deployment_name + ), + })?; + + let builder = AzureRequestBuilder::new(Method::PUT, url) + .content_type_json() + .content_length(&body) + .body(body); + + let req = builder.build()?; + let signed = self.base.sign_request(req, &bearer_token).await?; + + self.base + .execute_request_with_long_running_support( + signed, + "CreateCognitiveServicesDeployment", + deployment_name, + ) + .await + } + + /// Get a model deployment's properties + async fn get_deployment( + &self, + resource_group_name: &str, + account_name: &str, + deployment_name: &str, + ) -> Result { + let bearer_token = self + .token_cache + .get_bearer_token_with_scope("https://management.azure.com/.default") + .await?; + + let url = self.base.build_url( + &self.deployment_url(resource_group_name, account_name, deployment_name), + Some(vec![("api-version", COGNITIVE_SERVICES_API_VERSION.into())]), + ); + + let builder = AzureRequestBuilder::new(Method::GET, url.clone()).content_length(""); + + let req = builder.build()?; + let signed = self.base.sign_request(req, &bearer_token).await?; + let resp = self + .base + .execute_request(signed, "GetCognitiveServicesDeployment", deployment_name) + .await?; + + let body = resp + .text() + .await + .into_alien_error() + .context(ErrorData::HttpResponseError { + message: format!( + "Azure GetCognitiveServicesDeployment: failed to read response body for {}", + deployment_name + ), + url: url.clone(), + http_status: 200, + http_request_text: None, + http_response_text: None, + })?; + + let deployment: CognitiveServicesDeployment = serde_json::from_str(&body) + .into_alien_error() + .context(ErrorData::HttpResponseError { + message: format!( + "Azure GetCognitiveServicesDeployment: JSON parse error for {}", + deployment_name + ), + url: url.clone(), + http_status: 200, + http_request_text: None, + http_response_text: Some(body), + })?; + + Ok(deployment) + } +} + +// ------------------------------------------------------------------------- +// Tests +// ------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uses_current_cognitive_services_api_version() { + assert_eq!(COGNITIVE_SERVICES_API_VERSION, "2024-10-01"); + } + + /// Verifies that the ARM GET response for a CognitiveServices account deserializes + /// correctly into our hand-written model. This catches camelCase/field-name mistakes. + #[test] + fn deserializes_arm_get_response() { + let json = r#"{ + "kind": "AIServices", + "location": "eastus", + "sku": { "name": "S0" }, + "properties": { + "endpoint": "https://my-account.cognitiveservices.azure.com/", + "provisioningState": "Succeeded" + } + }"#; + + let account: CognitiveServicesAccount = + serde_json::from_str(json).expect("ARM GET response should deserialize"); + + assert_eq!(account.kind.as_deref(), Some("AIServices")); + assert_eq!(account.location.as_deref(), Some("eastus")); + assert_eq!(account.sku.as_ref().map(|s| s.name.as_str()), Some("S0")); + + let props = account.properties.expect("properties should be present"); + assert_eq!( + props.endpoint.as_deref(), + Some("https://my-account.cognitiveservices.azure.com/") + ); + assert_eq!(props.provisioning_state.as_deref(), Some("Succeeded")); + } + + /// Verifies that the create parameters serialize to the expected ARM request body shape. + #[test] + fn serializes_create_parameters() { + let params = CognitiveServicesAccountCreateParameters { + location: "eastus".to_string(), + kind: "AIServices".to_string(), + sku: CognitiveServicesSku { + name: "S0".to_string(), + }, + properties: CognitiveServicesAccountCreateProperties { + custom_sub_domain_name: "my-account".to_string(), + }, + }; + + let json = serde_json::to_string(¶ms).expect("should serialize"); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(value["kind"], "AIServices"); + assert_eq!(value["location"], "eastus"); + assert_eq!(value["sku"]["name"], "S0"); + assert_eq!(value["properties"]["customSubDomainName"], "my-account"); + } + + /// The deployment create body must match the ARM shape: sku {name,capacity} and + /// properties.model {format,name,version}. + #[test] + fn serializes_deployment_create_parameters() { + let params = CognitiveServicesDeploymentCreateParameters { + sku: CognitiveServicesDeploymentSku { + name: "GlobalStandard".to_string(), + capacity: 1, + }, + properties: CognitiveServicesDeploymentCreateProperties { + model: CognitiveServicesDeploymentModel { + format: "OpenAI".to_string(), + name: "gpt-4.1".to_string(), + version: "2025-04-14".to_string(), + }, + }, + }; + + let value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(¶ms).unwrap()).unwrap(); + + assert_eq!(value["sku"]["name"], "GlobalStandard"); + assert_eq!(value["sku"]["capacity"], 1); + assert_eq!(value["properties"]["model"]["format"], "OpenAI"); + assert_eq!(value["properties"]["model"]["name"], "gpt-4.1"); + assert_eq!(value["properties"]["model"]["version"], "2025-04-14"); + } + + /// The ARM GET response for a deployment must deserialize, including the + /// provisioning state the controller polls on. + #[test] + fn deserializes_deployment_get_response() { + let json = r#"{ + "sku": { "name": "GlobalStandard", "capacity": 1 }, + "properties": { + "model": { "format": "OpenAI", "name": "gpt-4.1", "version": "2025-04-14" }, + "provisioningState": "Succeeded" + } + }"#; + + let deployment: CognitiveServicesDeployment = + serde_json::from_str(json).expect("ARM GET deployment response should deserialize"); + + let props = deployment.properties.expect("properties should be present"); + assert_eq!(props.model.name, "gpt-4.1"); + assert_eq!(props.model.version, "2025-04-14"); + assert_eq!(props.provisioning_state.as_deref(), Some("Succeeded")); + } +} + +#[cfg(all(test, feature = "test-utils"))] +mod mock_tests { + use super::*; + + /// Happy-path round-trip: create, get, delete using the mock. Confirms the mock + /// generated by automock compiles and satisfies the trait surface. + #[tokio::test] + async fn mock_create_get_delete_round_trip() { + let mut mock = MockCognitiveServicesAccountsApi::new(); + + mock.expect_create_account() + .returning(|_, _, _| { + Ok(OperationResult::Completed(CognitiveServicesAccount { + kind: Some("AIServices".to_string()), + location: Some("eastus".to_string()), + sku: Some(CognitiveServicesSku { + name: "S0".to_string(), + }), + properties: Some(CognitiveServicesAccountProperties { + endpoint: Some( + "https://my-account.cognitiveservices.azure.com/".to_string(), + ), + provisioning_state: Some("Succeeded".to_string()), + }), + })) + }); + + mock.expect_get_account().returning(|_, _| { + Ok(CognitiveServicesAccount { + kind: Some("AIServices".to_string()), + location: Some("eastus".to_string()), + sku: Some(CognitiveServicesSku { + name: "S0".to_string(), + }), + properties: Some(CognitiveServicesAccountProperties { + endpoint: Some( + "https://my-account.cognitiveservices.azure.com/".to_string(), + ), + provisioning_state: Some("Succeeded".to_string()), + }), + }) + }); + + mock.expect_delete_account().returning(|_, _| Ok(())); + + let params = CognitiveServicesAccountCreateParameters { + location: "eastus".to_string(), + kind: "AIServices".to_string(), + sku: CognitiveServicesSku { + name: "S0".to_string(), + }, + properties: CognitiveServicesAccountCreateProperties { + custom_sub_domain_name: "my-account".to_string(), + }, + }; + + let create_result = mock + .create_account("my-rg", "my-account", ¶ms) + .await + .expect("create should succeed"); + + let account = match create_result { + OperationResult::Completed(a) => a, + OperationResult::LongRunning(_) => panic!("expected completed result from mock"), + }; + + let props = account.properties.expect("properties should be present"); + assert_eq!( + props.endpoint.as_deref(), + Some("https://my-account.cognitiveservices.azure.com/") + ); + assert_eq!(props.provisioning_state.as_deref(), Some("Succeeded")); + + let gotten = mock + .get_account("my-rg", "my-account") + .await + .expect("get should succeed"); + + assert_eq!( + gotten + .properties + .as_ref() + .and_then(|p| p.endpoint.as_deref()), + Some("https://my-account.cognitiveservices.azure.com/") + ); + + mock.delete_account("my-rg", "my-account") + .await + .expect("delete should succeed"); + } +} diff --git a/crates/alien-azure-clients/src/azure/mod.rs b/crates/alien-azure-clients/src/azure/mod.rs index 06cf6a6f6..b252da790 100644 --- a/crates/alien-azure-clients/src/azure/mod.rs +++ b/crates/alien-azure-clients/src/azure/mod.rs @@ -12,6 +12,7 @@ pub use alien_core::{ pub mod application_gateways; pub mod authorization; pub mod blob_containers; +pub mod cognitive_services; pub mod common; pub mod compute; pub mod container_apps; diff --git a/crates/alien-azure-clients/src/lib.rs b/crates/alien-azure-clients/src/lib.rs index 21e266578..8b6fc5373 100644 --- a/crates/alien-azure-clients/src/lib.rs +++ b/crates/alien-azure-clients/src/lib.rs @@ -9,6 +9,9 @@ pub use azure::{ // Re-export all client APIs pub use azure::application_gateways::{ApplicationGatewayApi, AzureApplicationGatewayClient}; +pub use azure::cognitive_services::{ + AzureCognitiveServicesClient, CognitiveServicesAccountsApi, +}; pub use azure::authorization::{AuthorizationApi, AzureAuthorizationClient}; pub use azure::blob_containers::{AzureBlobContainerClient, BlobContainerApi}; pub use azure::compute::{AzureVmssClient, VirtualMachineScaleSetsApi}; diff --git a/crates/alien-bindings/src/provider.rs b/crates/alien-bindings/src/provider.rs index 4a9c7a72e..81394c91a 100644 --- a/crates/alien-bindings/src/provider.rs +++ b/crates/alien-bindings/src/provider.rs @@ -117,6 +117,14 @@ impl BindingsProvider { }) } + /// The workload's resolved cloud credentials (native/projected identity or Alien-minted + /// short-lived credentials). Consumers that need to authorize a request themselves — the + /// AI gateway signs upstream model calls with this — read it here rather than going + /// through a per-resource binding. + pub fn client_config(&self) -> &ClientConfig { + &self.client_config + } + /// Get a cached binding by type and name, or return None. async fn get_cached( &self, @@ -468,7 +476,7 @@ impl LazyEnvBindingsProvider { /// The strategy selection happens exactly once (via `OnceCell`); on the /// minting path each call additionally checks credential freshness and /// re-mints on access if stale. - async fn provider(&self) -> Result> { + pub async fn provider(&self) -> Result> { let resolver = self .resolver .get_or_try_init(|| async { self.select().await }) diff --git a/crates/alien-bindings/src/traits.rs b/crates/alien-bindings/src/traits.rs index 9770f820d..d200211e0 100644 --- a/crates/alien-bindings/src/traits.rs +++ b/crates/alien-bindings/src/traits.rs @@ -825,13 +825,15 @@ pub trait BindingsProviderApi: Send + Sync + std::fmt::Debug { /// Given a binding identifier, builds a ServiceAccount implementation. async fn load_service_account(&self, binding_name: &str) -> Result>; - /// Runtime-only binding env vars (a local Postgres connection with its password) for the given - /// resource — re-resolved on every (re)start so the secret reaches the worker process but is - /// never written to persisted worker metadata. Default `None`: cloud providers carry a secret - /// locator (not a password) and use the normal persisted path. + /// Runtime-only binding env vars (a local Postgres connection with its password, a local + /// BYO-key AI binding) for the given resource — re-resolved on every (re)start so the secret + /// reaches the worker process but is never written to persisted worker metadata. The resource + /// type routes resolution to the right local source. Default `None`: cloud providers carry a + /// secret locator (not a raw secret) and use the normal persisted path. async fn resolve_runtime_only_binding_env( &self, _binding_name: &str, + _resource_type: &str, ) -> Result>> { Ok(None) } diff --git a/crates/alien-cli/src/commands/init.rs b/crates/alien-cli/src/commands/init.rs index 9301fcab6..ec8f1d04d 100644 --- a/crates/alien-cli/src/commands/init.rs +++ b/crates/alien-cli/src/commands/init.rs @@ -62,6 +62,14 @@ const KNOWN_TEMPLATES: &[(&str, &str)] = &[ "nextjs-app", "Deploy a Next.js app as a single container in the customer's cloud.", ), + ( + "ai-chatbot-ts", + "A streaming AI chatbot that calls cloud LLMs through the embedded AI gateway.", + ), + ( + "ai-quickstart-ts", + "The smallest AI setup: one worker calling cloud LLMs, no API keys, no database.", + ), ]; fn fallback_templates() -> Vec { diff --git a/crates/alien-cloudformation/src/built_ins.rs b/crates/alien-cloudformation/src/built_ins.rs index 61d6440ee..d71535b14 100644 --- a/crates/alien-cloudformation/src/built_ins.rs +++ b/crates/alien-cloudformation/src/built_ins.rs @@ -5,15 +5,16 @@ use crate::{ emitters::aws::{ - AwsArtifactRegistryEmitter, AwsBuildEmitter, AwsEmailEmitter, AwsKubernetesClusterEmitter, - AwsKvEmitter, AwsNetworkEmitter, AwsOpenSearchEmitter, AwsQueueEmitter, - AwsRemoteStackManagementEmitter, AwsServiceAccountEmitter, AwsStorageEmitter, - AwsVaultEmitter, AwsWorkerEmitter, + AwsAiEmitter, AwsArtifactRegistryEmitter, AwsBuildEmitter, AwsEmailEmitter, + AwsKubernetesClusterEmitter, AwsKvEmitter, AwsNetworkEmitter, AwsOpenSearchEmitter, + AwsQueueEmitter, AwsRemoteStackManagementEmitter, AwsServiceAccountEmitter, + AwsStorageEmitter, AwsVaultEmitter, AwsWorkerEmitter, }, registry::CfRegistry, }; use alien_core::{ - ArtifactRegistry, AwsOpenSearch, Build, Email, KubernetesCluster, Kv, Network, Platform, Queue, + Ai, ArtifactRegistry, AwsOpenSearch, Build, Email, KubernetesCluster, Kv, Network, Platform, + Queue, RemoteStackManagement, ResourceType, ServiceAccount, Storage, Vault, Worker, }; @@ -25,6 +26,7 @@ pub(crate) fn register_aws(registry: &mut CfRegistry) { registry.register(resource_type, Platform::Aws, emitter); } + aws(registry, Ai::RESOURCE_TYPE, AwsAiEmitter); aws(registry, Storage::RESOURCE_TYPE, AwsStorageEmitter); // Experimental resources are provider-specific: AwsOpenSearch only // registers an AWS emitter, so other platforms fail with a typed diff --git a/crates/alien-cloudformation/src/emitters/aws/ai.rs b/crates/alien-cloudformation/src/emitters/aws/ai.rs new file mode 100644 index 000000000..a9c33a2f6 --- /dev/null +++ b/crates/alien-cloudformation/src/emitters/aws/ai.rs @@ -0,0 +1,145 @@ +//! AWS AI — Bedrock inference gateway. +//! +//! AWS Bedrock is a regional, account-scoped service with no per-stack +//! cloud resource to provision. The emitter returns zero CFN resources for +//! the AI resource itself and emits `AWS::IAM::Policy` resources for every +//! permission profile that references `ai/invoke` on this resource, attaching +//! them to the corresponding service-account role. +//! +//! The import ref carries the region so the controller can reconstruct the +//! Bedrock endpoint without a cloud round-trip. + +use crate::{ + emitter::CfEmitter, + emitters::aws::{ + helpers::{ + cf_from_json, required_logical_id, resource_config, service_account_role_id, + uniquify_iam_statement_sids, + }, + service_account::permission_context, + }, + template::{CfExpression, CfResource}, +}; +use alien_core::{import::EmitContext, Ai, ErrorData, PermissionProfile, PermissionSetReference, Result}; +use alien_error::{AlienError, Context, IntoAlienError}; +use alien_permissions::{generators::AwsCloudFormationPermissionsGenerator, BindingTarget}; + +#[derive(Debug, Clone, Copy, Default)] +pub struct AwsAiEmitter; + +impl CfEmitter for AwsAiEmitter { + fn emit_resources(&self, ctx: &EmitContext<'_>) -> Result> { + resource_config::(ctx, Ai::RESOURCE_TYPE)?; + ai_iam_policies(ctx) + } + + fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { + resource_config::(ctx, Ai::RESOURCE_TYPE)?; + Ok(CfExpression::object([("region", CfExpression::ref_("AWS::Region"))])) + } +} + +fn ai_iam_policies(ctx: &EmitContext<'_>) -> Result> { + let mut resources = Vec::new(); + let generator = AwsCloudFormationPermissionsGenerator::new(); + let logical_id = required_logical_id(ctx)?; + let context = permission_context(); + + for (owner_index, (role_id, permission_refs)) in ai_permission_owners(ctx).into_iter().enumerate() { + for (permission_index, permission_ref) in permission_refs.iter().enumerate() { + let Some(permission_set) = + permission_ref.resolve(|name| alien_permissions::get_permission_set(name).cloned()) + else { + continue; + }; + + let policy = generator + .generate_policy(&permission_set, BindingTarget::Resource, &context) + .context(ErrorData::GenericError { + message: format!( + "failed to generate AWS CloudFormation AI IAM policy for '{}'", + ctx.resource_id + ), + })?; + let policy_value = serde_json::to_value(policy).into_alien_error().context( + ErrorData::TemplateSerializationFailed { + format: "CloudFormation IAM policy".to_string(), + reason: "Failed to serialize AI IAM policy".to_string(), + }, + )?; + let CfExpression::Object(mut policy_object) = cf_from_json(policy_value)? else { + return Err(AlienError::new(ErrorData::TemplateSerializationFailed { + format: "CloudFormation IAM policy".to_string(), + reason: "AI policy did not serialize to a JSON object".to_string(), + })); + }; + let Some(CfExpression::List(policy_statements)) = + policy_object.shift_remove("Statement") + else { + continue; + }; + + let policy_id = format!( + "{logical_id}{role_id}AiPermission{owner_index}{permission_index}" + ); + let mut policy_resource = + CfResource::new(policy_id, "AWS::IAM::Policy".to_string()); + policy_resource.properties.insert( + "PolicyName".to_string(), + CfExpression::sub(format!( + "${{AWS::StackName}}-{}-ai-{owner_index}-{permission_index}", + ctx.resource_id + )), + ); + policy_resource.properties.insert( + "PolicyDocument".to_string(), + CfExpression::object([ + ("Version", CfExpression::from("2012-10-17")), + ( + "Statement", + CfExpression::list(uniquify_iam_statement_sids(policy_statements)), + ), + ]), + ); + policy_resource.properties.insert( + "Roles".to_string(), + CfExpression::list([CfExpression::ref_(&role_id)]), + ); + policy_resource.depends_on.push(role_id.clone()); + resources.push(policy_resource); + } + } + + Ok(resources) +} + +fn ai_permission_owners(ctx: &EmitContext<'_>) -> Vec<(String, Vec)> { + let mut owners = Vec::new(); + for (profile_name, profile) in ctx.stack.permission_profiles() { + let refs = ai_permission_refs(profile, ctx.resource_id); + if refs.is_empty() { + continue; + } + if let Some(role_id) = service_account_role_id(ctx, profile_name) { + owners.push((role_id, refs)); + } + } + owners +} + +fn ai_permission_refs( + profile: &PermissionProfile, + resource_id: &str, +) -> Vec { + let mut refs = Vec::new(); + let mut seen_ids = std::collections::HashSet::new(); + if let Some(resource_refs) = profile.0.get(resource_id) { + for permission_ref in resource_refs { + if seen_ids.insert(permission_ref.id().to_string()) { + refs.push(permission_ref.clone()); + } + } + } + refs +} + diff --git a/crates/alien-cloudformation/src/emitters/aws/mod.rs b/crates/alien-cloudformation/src/emitters/aws/mod.rs index 777e90040..2dcade453 100644 --- a/crates/alien-cloudformation/src/emitters/aws/mod.rs +++ b/crates/alien-cloudformation/src/emitters/aws/mod.rs @@ -5,6 +5,7 @@ //! Built-ins are wired through [`crate::CfRegistry::built_in`]; plugins //! register additional implementations against the same registry. +pub mod ai; pub mod artifact_registry; pub mod build; pub mod email; @@ -20,6 +21,7 @@ pub mod storage; pub mod vault; pub mod worker; +pub use ai::AwsAiEmitter; pub use artifact_registry::AwsArtifactRegistryEmitter; pub use build::AwsBuildEmitter; pub use email::AwsEmailEmitter; diff --git a/crates/alien-cloudformation/tests/generator.rs b/crates/alien-cloudformation/tests/generator.rs index f3899a2de..6270e7b05 100644 --- a/crates/alien-cloudformation/tests/generator.rs +++ b/crates/alien-cloudformation/tests/generator.rs @@ -10,6 +10,7 @@ mod generator { pub mod helpers; + pub mod aws_ai_tests; pub mod aws_compute_tests; pub mod aws_data_layer_tests; pub mod aws_email_tests; diff --git a/crates/alien-cloudformation/tests/generator/aws_ai_tests.rs b/crates/alien-cloudformation/tests/generator/aws_ai_tests.rs new file mode 100644 index 000000000..653853941 --- /dev/null +++ b/crates/alien-cloudformation/tests/generator/aws_ai_tests.rs @@ -0,0 +1,77 @@ +//! AWS AI (Bedrock) CloudFormation emitter tests. + +use super::helpers::render_built_ins; +use alien_cloudformation::RegistrationMode; +use alien_core::{Ai, PermissionProfile, ResourceLifecycle, ServiceAccount, Stack, StackSettings}; + +#[test] +fn aws_ai_invoke_permissions_attach_to_service_account_role() { + let stack = Stack::new("ai-permissions".to_string()) + .permission( + "execution", + PermissionProfile::new().resource("llm", ["ai/invoke"]), + ) + .add( + ServiceAccount::new("execution-sa".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add(Ai::new("llm".to_string()).build(), ResourceLifecycle::Frozen) + .build(); + + let yaml = render_built_ins( + &stack, + StackSettings::default(), + RegistrationMode::OutputsFallback, + "aws ai bedrock invoke permissions", + ); + + // Policy resource is emitted and attached to the execution service-account role. + assert!( + yaml.contains("ExecutionSaRole"), + "expected ExecutionSaRole in template:\n{yaml}" + ); + // Bedrock actions are present. + assert!( + yaml.contains("bedrock:InvokeModel"), + "expected bedrock:InvokeModel in template:\n{yaml}" + ); + assert!( + yaml.contains("bedrock:InvokeModelWithResponseStream"), + "expected bedrock:InvokeModelWithResponseStream in template:\n{yaml}" + ); + // Bedrock foundation-model resource scope is present. + assert!( + yaml.contains("foundation-model"), + "expected arn:aws:bedrock:*::foundation-model/* in template:\n{yaml}" + ); + + // Snapshot the full rendered template so the IAM::Policy structure — role ref, + // DependsOn, logical-id uniqueness, SID shape — is proven at the artifact level, + // matching the terraform path (render_built_ins already runs cfn-lint on it). + insta::assert_snapshot!("aws_ai_invoke_permissions", yaml); +} + +#[test] +fn aws_ai_without_permissions_emits_no_iam_policy() { + // An Ai resource with no permission profile referencing it should emit + // zero IAM resources (the AI itself creates no cloud resource on AWS). + let stack = Stack::new("ai-no-permissions".to_string()) + .add(Ai::new("llm".to_string()).build(), ResourceLifecycle::Frozen) + .build(); + + let yaml = render_built_ins( + &stack, + StackSettings::default(), + RegistrationMode::OutputsFallback, + "aws ai no permissions", + ); + + assert!( + !yaml.contains("bedrock:InvokeModel"), + "expected no bedrock actions without a permission profile:\n{yaml}" + ); + assert!( + !yaml.contains("AWS::IAM::Policy"), + "expected no IAM policy without a permission profile:\n{yaml}" + ); +} diff --git a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_ai_tests__aws_ai_invoke_permissions.snap b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_ai_tests__aws_ai_invoke_permissions.snap new file mode 100644 index 000000000..b41ba520e --- /dev/null +++ b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_ai_tests__aws_ai_invoke_permissions.snap @@ -0,0 +1,191 @@ +--- +source: crates/alien-cloudformation/tests/generator/aws_ai_tests.rs +expression: yaml +--- +AWSTemplateFormatVersion: 2010-09-09 +Description: aws ai bedrock invoke permissions +Transform: +- AWS::LanguageExtensions +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: Registration + Parameters: + - Token + - ManagingRoleArn + - ManagingAccountId + - Label: + default: Operations + Parameters: + - UpdatesMode + - TelemetryMode + - HeartbeatsMode + ParameterLabels: + HeartbeatsMode: + default: Heartbeats + ManagingAccountId: + default: Image account ID + ManagingRoleArn: + default: Management role ARN + TelemetryMode: + default: Telemetry + Token: + default: Install token + UpdatesMode: + default: Updates +Parameters: + Token: + Type: String + Description: Install token from the application setup page. + NoEcho: true + ManagingRoleArn: + Type: String + Description: ARN of the management identity allowed to assume setup-created roles. + Default: '' + ManagingAccountId: + Type: String + Description: AWS account ID for the management account that hosts application container images. + Default: '' + NetworkMode: + Type: String + Description: Choose create-new for a managed VPC, use-existing for your VPC, or use-default for the account default VPC. + Default: create-new + AllowedValues: + - create-new + - use-existing + - use-default + UpdatesMode: + Type: String + Description: How updates are applied after setup registration. + Default: auto + AllowedValues: + - auto + - approval-required + TelemetryMode: + Type: String + Description: Telemetry collection behavior. + Default: auto + AllowedValues: + - auto + HeartbeatsMode: + Type: String + Description: Heartbeat health-check behavior. + Default: "on" + AllowedValues: + - "off" + - "on" +Resources: + ExecutionSaRole: + Type: AWS::IAM::Role + Properties: + RoleName: + Fn::Sub: ${AWS::StackName}-execution-sa + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - codebuild.amazonaws.com + - ec2.amazonaws.com + - lambda.amazonaws.com + Action: sts:AssumeRole + Tags: + - Key: managed-by + Value: setup + - Key: deployment + Value: + Ref: AWS::StackName + - Key: resource + Value: execution-sa + - Key: resource-type + Value: service-account + LlmExecutionSaRoleAiPermission00: + Type: AWS::IAM::Policy + Properties: + PolicyName: + Fn::Sub: ${AWS::StackName}-llm-ai-0-0 + PolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - bedrock-mantle:CreateInference + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Effect: Allow + Resource: + - Fn::Sub: arn:${AWS::Partition}:bedrock-mantle:*:${AWS::AccountId}:project/default + - Fn::Sub: arn:${AWS::Partition}:bedrock:*:${AWS::AccountId}:inference-profile/* + - Fn::Sub: arn:${AWS::Partition}:bedrock:*::foundation-model/* + Sid: AiInvoke + Roles: + - Ref: ExecutionSaRole + DependsOn: + - ExecutionSaRole +Outputs: + DeploymentSourceKind: + Value: cloudformation + Description: Setup source kind. + DeploymentResourcePrefix: + Value: + Ref: AWS::StackName + Description: Stable physical resource prefix. + DeploymentPlatform: + Value: aws + Description: Target platform. + DeploymentRegion: + Value: + Ref: AWS::Region + Description: AWS region. + DeploymentSetupTarget: + Value: aws + Description: Setup target. + DeploymentSetupFingerprint: + Value: test + Description: Setup compatibility fingerprint. + DeploymentSetupImportFormatVersion: + Value: 1 + Description: Setup registration payload format version. + DeploymentSetupFingerprintVersion: + Value: 1 + Description: Setup fingerprint algorithm version. + DeploymentManagementConfig: + Value: + Fn::ToJsonString: + platform: aws + managingRoleArn: + Ref: ManagingRoleArn + Description: Deployment registration management configuration JSON. + DeploymentStackSettings: + Value: + Fn::ToJsonString: + deploymentModel: push + updates: + Ref: UpdatesMode + telemetry: + Ref: TelemetryMode + heartbeats: + Ref: HeartbeatsMode + network: + Ref: AWS::NoValue + Description: Deployment registration settings JSON. + DeploymentResources: + Value: + Fn::ToJsonString: + - id: execution-sa + type: service-account + importData: + roleName: + Ref: ExecutionSaRole + roleArn: + Fn::GetAtt: + - ExecutionSaRole + - Arn + stackPermissionsApplied: true + - id: llm + type: ai + importData: + region: + Ref: AWS::Region + Description: Deployment registration resources JSON. diff --git a/crates/alien-core/src/ai_catalog.rs b/crates/alien-core/src/ai_catalog.rs new file mode 100644 index 000000000..42f983616 --- /dev/null +++ b/crates/alien-core/src/ai_catalog.rs @@ -0,0 +1,381 @@ +//! Curated, per-cloud model catalog for the AI gateway. +//! +//! Single source of truth for which public model ids each cloud exposes, the +//! upstream id the gateway forwards, and the wire protocol of the model's native +//! endpoint. Backs `getAvailableModels()` and the gateway's `/v1/models`, and the +//! Azure controller deploys the Azure entries as named deployments at provision +//! time (see `azure_deployments`). +//! +//! A model is includable only if its cloud serves it over a protocol the client +//! SDK already speaks (OpenAI Chat Completions or Anthropic Messages), so the +//! gateway forwards the request body untranslated. + +use crate::Platform; +use serde::{Deserialize, Serialize}; + +/// The upstream wire protocol a model speaks. The gateway forwards to the +/// matching native endpoint; the client SDK is responsible for speaking it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + /// OpenAI Chat Completions (`/v1/chat/completions`). + OpenAi, + /// Anthropic Messages (`/v1/messages`). + Anthropic, +} + +/// One curated model: the public id an app requests, the cloud that serves it, +/// the upstream id the gateway forwards (for Azure this is the deployment name), +/// and the protocol of its native endpoint. +#[derive(Debug, Clone)] +pub struct CatalogModel { + pub public_id: &'static str, + pub cloud: Platform, + pub upstream_id: &'static str, + pub protocol: Protocol, +} + +static CATALOG: &[CatalogModel] = &[ + // AWS Bedrock over `/openai/v1` chat completions. The plain Bedrock model id, + // not the `us.*` cross-region inference profile — that endpoint rejects it. + // Invoke/Converse-only models (older Llama/Mistral-v0/Nova) can't be served here. + CatalogModel { public_id: "gpt-oss-20b", cloud: Platform::Aws, upstream_id: "openai.gpt-oss-20b-1:0", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "gpt-oss-120b", cloud: Platform::Aws, upstream_id: "openai.gpt-oss-120b-1:0", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "gpt-oss-safeguard-20b", cloud: Platform::Aws, upstream_id: "openai.gpt-oss-safeguard-20b", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "gpt-oss-safeguard-120b", cloud: Platform::Aws, upstream_id: "openai.gpt-oss-safeguard-120b", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "deepseek-v3.2", cloud: Platform::Aws, upstream_id: "deepseek.v3.2", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "qwen3-32b", cloud: Platform::Aws, upstream_id: "qwen.qwen3-32b-v1:0", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "qwen3-coder-30b", cloud: Platform::Aws, upstream_id: "qwen.qwen3-coder-30b-a3b-v1:0", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "qwen3-coder-next", cloud: Platform::Aws, upstream_id: "qwen.qwen3-coder-next", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "qwen3-next-80b", cloud: Platform::Aws, upstream_id: "qwen.qwen3-next-80b-a3b", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "qwen3-vl-235b", cloud: Platform::Aws, upstream_id: "qwen.qwen3-vl-235b-a22b", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "mistral-large-3", cloud: Platform::Aws, upstream_id: "mistral.mistral-large-3-675b-instruct", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "devstral-2", cloud: Platform::Aws, upstream_id: "mistral.devstral-2-123b", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "magistral-small", cloud: Platform::Aws, upstream_id: "mistral.magistral-small-2509", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "ministral-3-14b", cloud: Platform::Aws, upstream_id: "mistral.ministral-3-14b-instruct", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "ministral-3-8b", cloud: Platform::Aws, upstream_id: "mistral.ministral-3-8b-instruct", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "ministral-3-3b", cloud: Platform::Aws, upstream_id: "mistral.ministral-3-3b-instruct", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "minimax-m2", cloud: Platform::Aws, upstream_id: "minimax.minimax-m2", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "minimax-m2.1", cloud: Platform::Aws, upstream_id: "minimax.minimax-m2.1", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "minimax-m2.5", cloud: Platform::Aws, upstream_id: "minimax.minimax-m2.5", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "kimi-k2.5", cloud: Platform::Aws, upstream_id: "moonshotai.kimi-k2.5", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "nemotron-nano-9b", cloud: Platform::Aws, upstream_id: "nvidia.nemotron-nano-9b-v2", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "nemotron-nano-12b", cloud: Platform::Aws, upstream_id: "nvidia.nemotron-nano-12b-v2", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "nemotron-nano-3-30b", cloud: Platform::Aws, upstream_id: "nvidia.nemotron-nano-3-30b", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "nemotron-super-3-120b", cloud: Platform::Aws, upstream_id: "nvidia.nemotron-super-3-120b", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "gemma-3-4b", cloud: Platform::Aws, upstream_id: "google.gemma-3-4b-it", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "gemma-3-12b", cloud: Platform::Aws, upstream_id: "google.gemma-3-12b-it", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "gemma-3-27b", cloud: Platform::Aws, upstream_id: "google.gemma-3-27b-it", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "glm-4.7", cloud: Platform::Aws, upstream_id: "zai.glm-4.7", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "glm-4.7-flash", cloud: Platform::Aws, upstream_id: "zai.glm-4.7-flash", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "glm-5", cloud: Platform::Aws, upstream_id: "zai.glm-5", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "palmyra-vision-7b", cloud: Platform::Aws, upstream_id: "writer.palmyra-vision-7b", protocol: Protocol::OpenAi }, + // AWS Bedrock, Claude over classic InvokeModel (the Anthropic Messages body is + // the InvokeModel body; the model travels in the URL). `upstream_id` is the plain + // Bedrock model id; the gateway prepends the region's cross-region inference-profile + // geo prefix (`us.`/`eu.`/`apac.`) at request time, since Claude is invocable only + // through a profile. Dated ids (`…--v1:0`) are required where AWS has no short + // alias. These need Claude model access granted on the deployment's account. + CatalogModel { public_id: "claude-sonnet-5", cloud: Platform::Aws, upstream_id: "anthropic.claude-sonnet-5", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.8", cloud: Platform::Aws, upstream_id: "anthropic.claude-opus-4-8", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.7", cloud: Platform::Aws, upstream_id: "anthropic.claude-opus-4-7", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.6", cloud: Platform::Aws, upstream_id: "anthropic.claude-opus-4-6-v1", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.5", cloud: Platform::Aws, upstream_id: "anthropic.claude-opus-4-5-20251101-v1:0", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.1", cloud: Platform::Aws, upstream_id: "anthropic.claude-opus-4-1-20250805-v1:0", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-sonnet-4.6", cloud: Platform::Aws, upstream_id: "anthropic.claude-sonnet-4-6", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-sonnet-4.5", cloud: Platform::Aws, upstream_id: "anthropic.claude-sonnet-4-5-20250929-v1:0", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-haiku-4.5", cloud: Platform::Aws, upstream_id: "anthropic.claude-haiku-4-5-20251001-v1:0", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-fable-5", cloud: Platform::Aws, upstream_id: "anthropic.claude-fable-5", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-mythos-5", cloud: Platform::Aws, upstream_id: "anthropic.claude-mythos-5", protocol: Protocol::Anthropic }, + // GCP Vertex, Gemini. The OpenAI-compatible Vertex endpoint expects the `google/` prefix. + // The 2.5 family serves in-region; the 3.x models serve on the `global` location. + CatalogModel { public_id: "gemini-2.5-pro", cloud: Platform::Gcp, upstream_id: "google/gemini-2.5-pro", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "gemini-2.5-flash", cloud: Platform::Gcp, upstream_id: "google/gemini-2.5-flash", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "gemini-2.5-flash-lite", cloud: Platform::Gcp, upstream_id: "google/gemini-2.5-flash-lite", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "gemini-3.5-flash", cloud: Platform::Gcp, upstream_id: "google/gemini-3.5-flash", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "gemini-3.1-flash-lite", cloud: Platform::Gcp, upstream_id: "google/gemini-3.1-flash-lite", protocol: Protocol::OpenAi }, + // GCP Vertex, Claude. The upstream id is the Vertex Model Garden id that travels + // in the `:rawPredict` URL path (`publishers/anthropic/models/`); models past + // Sonnet 4.5 carry no date suffix, older ones keep an `@` version. Needs + // Claude model access granted on the deployment's project. + CatalogModel { public_id: "claude-sonnet-5", cloud: Platform::Gcp, upstream_id: "claude-sonnet-5", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.8", cloud: Platform::Gcp, upstream_id: "claude-opus-4-8", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.7", cloud: Platform::Gcp, upstream_id: "claude-opus-4-7", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.6", cloud: Platform::Gcp, upstream_id: "claude-opus-4-6", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.5", cloud: Platform::Gcp, upstream_id: "claude-opus-4-5@20251101", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-sonnet-4.6", cloud: Platform::Gcp, upstream_id: "claude-sonnet-4-6", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-sonnet-4.5", cloud: Platform::Gcp, upstream_id: "claude-sonnet-4-5@20250929", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-haiku-4.5", cloud: Platform::Gcp, upstream_id: "claude-haiku-4-5@20251001", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-fable-5", cloud: Platform::Gcp, upstream_id: "claude-fable-5", protocol: Protocol::Anthropic }, + // Azure, OpenAI-protocol. The upstream id is the deployment name the controller + // creates (see AZURE_DEPLOYMENTS); the app requests it by the same id. Azure serves + // only what is deployed, so this list must stay in sync with AZURE_DEPLOYMENTS. + CatalogModel { public_id: "gpt-4.1", cloud: Platform::Azure, upstream_id: "gpt-4.1", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "gpt-4o-mini", cloud: Platform::Azure, upstream_id: "gpt-4o-mini", protocol: Protocol::OpenAi }, + CatalogModel { public_id: "model-router", cloud: Platform::Azure, upstream_id: "model-router", protocol: Protocol::OpenAi }, + // Azure, Claude over the Foundry Anthropic endpoint. The upstream id is the + // Foundry deployment name (defaults to the model id). Unlike the OpenAI list, + // these are not in AZURE_DEPLOYMENTS: a first Claude deployment requires + // accepting Azure Marketplace terms, a portal step the controller cannot + // perform, so Claude deployments are created in the Foundry portal. Until + // that portal step runs, /v1/models advertises these while Foundry answers + // "deployment not found" — a deliberate tradeoff so the deployment-name + // contract is discoverable; the upstream 404 passes through attributably. + CatalogModel { public_id: "claude-sonnet-5", cloud: Platform::Azure, upstream_id: "claude-sonnet-5", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.8", cloud: Platform::Azure, upstream_id: "claude-opus-4-8", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.7", cloud: Platform::Azure, upstream_id: "claude-opus-4-7", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.6", cloud: Platform::Azure, upstream_id: "claude-opus-4-6", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-opus-4.5", cloud: Platform::Azure, upstream_id: "claude-opus-4-5", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-sonnet-4.6", cloud: Platform::Azure, upstream_id: "claude-sonnet-4-6", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-sonnet-4.5", cloud: Platform::Azure, upstream_id: "claude-sonnet-4-5", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-haiku-4.5", cloud: Platform::Azure, upstream_id: "claude-haiku-4-5", protocol: Protocol::Anthropic }, + CatalogModel { public_id: "claude-fable-5", cloud: Platform::Azure, upstream_id: "claude-fable-5", protocol: Protocol::Anthropic }, +]; + +/// Azure deployments to create at provision time: (deployment name, model name, +/// model version). The deployment name is the catalog `upstream_id`. The version +/// is validated against the target region's model catalog at deploy time. +static AZURE_DEPLOYMENTS: &[(&str, &str, &str)] = &[ + ("gpt-4.1", "gpt-4.1", "2025-04-14"), + ("gpt-4o-mini", "gpt-4o-mini", "2024-07-18"), + ("model-router", "model-router", "2025-11-18"), +]; + +/// AWS models servable over the bedrock-mantle OpenAI Responses API, mapped to the +/// id that endpoint expects (mantle drops the InvokeModel version suffix, and only +/// a subset of the chat catalog supports Responses at all — Claude is Messages-only +/// and e.g. Qwen rejects it). Kept explicit rather than derived: the two id schemes +/// differ per model family, not by a rule. +static RESPONSES_UPSTREAM: &[(&str, &str)] = &[ + ("gpt-oss-20b", "openai.gpt-oss-20b"), + ("gpt-oss-120b", "openai.gpt-oss-120b"), +]; + +/// The bedrock-mantle Responses-API id for a public model id, or `None` when the +/// model is not servable over the Responses API. +pub fn responses_upstream_id(public_id: &str) -> Option<&'static str> { + RESPONSES_UPSTREAM + .iter() + .find(|(public, _)| *public == public_id) + .map(|(_, upstream)| *upstream) +} + +pub fn models_for(cloud: Platform) -> Vec<&'static CatalogModel> { + CATALOG.iter().filter(|m| m.cloud == cloud).collect() +} + +/// The catalog model for a public id, or `None` if it is not exposed. +/// +/// First match: for an id serving on more than one cloud this is the AWS entry; +/// cloud-scoped callers use `lookup_for` via `resolve_for`. +pub fn lookup(public_id: &str) -> Option<&'static CatalogModel> { + CATALOG.iter().find(|m| m.public_id == public_id) +} + +fn lookup_for(public_id: &str, cloud: Platform) -> Option<&'static CatalogModel> { + CATALOG.iter().find(|m| m.public_id == public_id && m.cloud == cloud) +} + +/// The catalog model for a client-sent model id on a specific cloud. A public id +/// can appear once per cloud (Claude serves on more than one), so resolution must +/// scope to the binding's cloud rather than filter a first-match lookup — the +/// first match is another cloud's entry whenever ids overlap. +pub fn resolve_for(model_id: &str, cloud: Platform) -> Option<&'static CatalogModel> { + lookup_for(model_id, cloud).or_else(|| lookup_for(&canonical_public_id(model_id), cloud)) +} + +/// The catalog model for a client-sent model id, accepting the Anthropic-native +/// spellings agent CLIs actually send alongside the catalog's public ids. +/// +/// Claude Code's `/model` emits ids like `claude-sonnet-4-5-20250929` or +/// `claude-haiku-4-5`, Bedrock-aware clients may carry the full upstream id +/// (`us.anthropic.claude-haiku-4-5-20251001-v1:0`), and Vertex clients the +/// `@date` form (`claude-sonnet-4-5@20250929`). Exact public ids win; otherwise +/// the id is canonicalized — vendor/geo prefix, InvokeModel `-vN[:M]` suffix, +/// and either release-date suffix drop off, and a dashed minor version becomes +/// the catalog's dotted form (`claude-haiku-4-5` → `claude-haiku-4.5`). +/// +/// A public id can appear once per cloud, and this returns the first catalog +/// entry — for a multi-cloud id that is the AWS one. Callers routing by a +/// binding must use `resolve_for` with the binding's cloud. +pub fn resolve(model_id: &str) -> Option<&'static CatalogModel> { + lookup(model_id).or_else(|| lookup(&canonical_public_id(model_id))) +} + +fn canonical_public_id(model_id: &str) -> String { + let mut id = model_id; + if let Some(pos) = id.rfind("anthropic.") { + id = &id[pos + "anthropic.".len()..]; + } + // Vertex spells the release date as an `@` suffix rather than a dash. + id = id.split_once('@').map_or(id, |(base, _)| base); + id = strip_invoke_version(id); + id = strip_release_date(id); + dot_minor_version(id) +} + +/// Strip an InvokeModel version suffix: `-v1:0` or `-v1`. +fn strip_invoke_version(id: &str) -> &str { + let base = id.split_once(':').map_or(id, |(base, _)| base); + match base.rsplit_once("-v") { + Some((stem, digits)) if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) => { + stem + } + _ => base, + } +} + +/// Strip a release-date suffix: `-20251001`. +fn strip_release_date(id: &str) -> &str { + match id.rsplit_once('-') { + Some((stem, date)) + if date.len() == 8 && date.starts_with("20") && date.bytes().all(|b| b.is_ascii_digit()) => + { + stem + } + _ => id, + } +} + +/// Rewrite a trailing dashed minor version to the catalog's dotted form: +/// `claude-haiku-4-5` → `claude-haiku-4.5`. Whole versions (`claude-sonnet-5`) +/// are already in catalog form and pass through. +fn dot_minor_version(id: &str) -> String { + let Some((stem, minor)) = id.rsplit_once('-') else { + return id.to_string(); + }; + let Some((prefix, major)) = stem.rsplit_once('-') else { + return id.to_string(); + }; + let both_numeric = !major.is_empty() + && !minor.is_empty() + && major.bytes().all(|b| b.is_ascii_digit()) + && minor.bytes().all(|b| b.is_ascii_digit()); + if both_numeric { + format!("{prefix}-{major}.{minor}") + } else { + id.to_string() + } +} + +/// The Azure predefined model deployments, as (deployment name, model name, version). +pub fn azure_deployments() -> Vec<(&'static str, &'static str, &'static str)> { + AZURE_DEPLOYMENTS.to_vec() +} + +#[cfg(test)] +mod tests { + /// A public id may serve on more than one cloud (Claude does), but must appear at + /// most once per cloud — a duplicate within a cloud would make `resolve_for` + /// silently pick whichever entry comes first. + #[test] + fn public_ids_are_unique_per_cloud() { + let mut seen = std::collections::HashSet::new(); + for model in super::CATALOG { + assert!( + seen.insert((model.cloud, model.public_id)), + "public id '{}' appears more than once under {:?}", + model.public_id, + model.cloud + ); + } + } + + use super::*; + + #[test] + fn resolve_accepts_anthropic_native_spellings() { + // Claude Code /model forms: dashed minor version, with and without date. + assert_eq!(resolve("claude-haiku-4-5").unwrap().public_id, "claude-haiku-4.5"); + assert_eq!( + resolve("claude-sonnet-4-5-20250929").unwrap().public_id, + "claude-sonnet-4.5" + ); + // Full Bedrock upstream ids, with geo/vendor prefix and version suffix. + assert_eq!( + resolve("us.anthropic.claude-haiku-4-5-20251001-v1:0").unwrap().public_id, + "claude-haiku-4.5" + ); + assert_eq!( + resolve("anthropic.claude-opus-4-6-v1").unwrap().public_id, + "claude-opus-4.6" + ); + // Whole versions are already catalog form. + assert_eq!(resolve("claude-sonnet-5").unwrap().public_id, "claude-sonnet-5"); + // Exact public ids still win untouched. + assert_eq!(resolve("claude-opus-4.8").unwrap().public_id, "claude-opus-4.8"); + assert_eq!(resolve("gpt-oss-20b").unwrap().public_id, "gpt-oss-20b"); + // Unknowns stay unknown — no fuzzy matching. + assert!(resolve("claude-nonexistent-9-9").is_none()); + assert!(resolve("gpt-5").is_none()); + } + + #[test] + fn aws_has_openai_and_anthropic_with_plain_ids() { + let aws = models_for(Platform::Aws); + assert!(!aws.is_empty()); + assert!(aws + .iter() + .any(|m| m.public_id == "gpt-oss-20b" && m.protocol == Protocol::OpenAi)); + assert!( + aws.iter().any(|m| m.protocol == Protocol::Anthropic), + "Claude must be included via the Anthropic protocol" + ); + // The OpenAI endpoint rejects `us.*` cross-region profile ids. + assert!(aws.iter().all(|m| !m.upstream_id.starts_with("us."))); + } + + #[test] + fn resolve_for_scopes_to_cloud() { + // The same public id serves on more than one cloud with different upstream + // ids, so resolution must scope to the binding's cloud. + let aws = resolve_for("claude-opus-4.8", Platform::Aws).expect("aws claude"); + assert_eq!(aws.upstream_id, "anthropic.claude-opus-4-8"); + let gcp = resolve_for("claude-opus-4.8", Platform::Gcp).expect("gcp claude"); + assert_eq!(gcp.upstream_id, "claude-opus-4-8"); + assert_eq!(gcp.protocol, Protocol::Anthropic); + // Canonicalization applies per cloud: Claude Code's dashed release-date + // spelling resolves to the Vertex `@date` id. + let dated = resolve_for("claude-haiku-4-5-20251001", Platform::Gcp).expect("dated id"); + assert_eq!(dated.upstream_id, "claude-haiku-4-5@20251001"); + // A Vertex-native `@date` spelling resolves too — it is the very id the + // GCP catalog stores upstream. + let vertex = resolve_for("claude-sonnet-4-5@20250929", Platform::Gcp).expect("vertex id"); + assert_eq!(vertex.upstream_id, "claude-sonnet-4-5@20250929"); + // A model serving on one cloud does not resolve on another. + assert!(resolve_for("gemini-2.5-pro", Platform::Aws).is_none()); + assert!(resolve_for("gpt-4.1", Platform::Gcp).is_none()); + } + + #[test] + fn lookup_round_trips() { + let m = lookup("gpt-oss-20b").expect("known model"); + assert_eq!(m.cloud, Platform::Aws); + assert_eq!(m.protocol, Protocol::OpenAi); + assert_eq!(m.upstream_id, "openai.gpt-oss-20b-1:0"); + + let c = lookup("claude-opus-4.8").expect("claude known"); + assert_eq!(c.protocol, Protocol::Anthropic); + + assert!(lookup("nonexistent-model").is_none()); + } + + #[test] + fn azure_deployments_map_to_catalog() { + assert!(!azure_deployments().is_empty()); + for (deployment, _, _) in azure_deployments() { + assert!( + models_for(Platform::Azure) + .iter() + .any(|m| m.upstream_id == deployment), + "azure deployment {deployment} must map to a catalog model" + ); + } + } + + #[test] + fn protocol_serializes_lowercase() { + assert_eq!(serde_json::to_string(&Protocol::OpenAi).unwrap(), "\"openai\""); + assert_eq!(serde_json::to_string(&Protocol::Anthropic).unwrap(), "\"anthropic\""); + } +} diff --git a/crates/alien-core/src/bin/schema_exporter.rs b/crates/alien-core/src/bin/schema_exporter.rs index 36e3fe441..67c45eccf 100644 --- a/crates/alien-core/src/bin/schema_exporter.rs +++ b/crates/alien-core/src/bin/schema_exporter.rs @@ -91,6 +91,8 @@ use utoipa::OpenApi; AwsOpenSearchOutputs, Postgres, PostgresOutputs, + Ai, + AiOutputs, Queue, QueueOutputs, Email, diff --git a/crates/alien-core/src/bindings/ai.rs b/crates/alien-core/src/bindings/ai.rs new file mode 100644 index 000000000..f2552cf1d --- /dev/null +++ b/crates/alien-core/src/bindings/ai.rs @@ -0,0 +1,193 @@ +//! AI Gateway binding definitions for managed AI inference across cloud providers. + +use super::BindingValue; +use serde::{Deserialize, Serialize}; + +/// Represents an AI Gateway binding for managed inference across cloud providers. +/// +/// The managed variants (Bedrock/Vertex/Foundry) carry only identifiers and +/// endpoints; authentication uses the workload's ambient cloud identity. The +/// `External` (BYO-key) variant deliberately carries a vault-resolved API key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +#[serde(tag = "service", rename_all = "lowercase")] +pub enum AiBinding { + /// AWS Bedrock AI binding + Bedrock(BedrockAiBinding), + /// GCP Vertex AI binding + Vertex(VertexAiBinding), + /// Azure AI Foundry binding + Foundry(FoundryAiBinding), + /// External provider binding (generic endpoint-based) + External(ExternalAiBinding), +} + +/// AWS Bedrock AI binding configuration +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct BedrockAiBinding { + /// The AWS region where Bedrock is accessed + pub region: String, +} + +/// GCP Vertex AI binding configuration +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct VertexAiBinding { + /// The GCP project ID + pub project: String, + /// The Vertex AI region (e.g., "us-central1") + pub location: String, +} + +/// Azure AI Foundry binding configuration +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct FoundryAiBinding { + /// The Foundry deployment endpoint URL + pub endpoint: String, + /// The Azure account or subscription identifier + pub account: String, +} + +/// External AI provider binding configuration (BYO-key). +/// +/// The operator-supplied secret rides inside the binding via +/// `BindingValue`, so it is a literal on cloud platforms and gains +/// Kubernetes SecretRef resolution for free (`extract_binding_secrets` walks +/// the binding JSON for `secretRef`). +// No derived `Debug` — an inline `api_key` would print cleartext; see the redacting impl below. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct ExternalAiBinding { + /// The external AI provider name (e.g., "openai", "anthropic") + pub provider: String, + /// The provider API key. Resolved to plaintext in the worker environment + /// (literal on cloud, Kubernetes Secret on K8s) so the SDK reads it directly. + pub api_key: BindingValue, +} + +// Redacts the inline key and keeps every other field, mirroring the external +// Postgres binding's redacting impl. +impl std::fmt::Debug for ExternalAiBinding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExternalAiBinding") + .field("provider", &self.provider) + .field("api_key", &"") + .finish() + } +} + +impl AiBinding { + /// The env var a developer sets to bring their own provider key on the Local platform. + /// Shared by the Local controller (provision-time check) and the local bindings + /// resolver (runtime-only re-resolution), so the two never drift. + pub const LOCAL_API_KEY_ENV: &'static str = "OPENAI_API_KEY"; + /// The BYO-key provider assumed on the Local platform. + pub const LOCAL_DEFAULT_PROVIDER: &'static str = "openai"; + + pub fn bedrock(region: impl Into) -> Self { + Self::Bedrock(BedrockAiBinding { + region: region.into(), + }) + } + + pub fn vertex(project: impl Into, location: impl Into) -> Self { + Self::Vertex(VertexAiBinding { + project: project.into(), + location: location.into(), + }) + } + + pub fn foundry(endpoint: impl Into, account: impl Into) -> Self { + Self::Foundry(FoundryAiBinding { + endpoint: endpoint.into(), + account: account.into(), + }) + } + + pub fn external( + provider: impl Into, + api_key: impl Into>, + ) -> Self { + Self::External(ExternalAiBinding { + provider: provider.into(), + api_key: api_key.into(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bedrock_binding_roundtrip() { + let binding = AiBinding::bedrock("us-east-1"); + + let json = serde_json::to_string(&binding).unwrap(); + assert!(json.contains(r#""service":"bedrock""#)); + + let deserialized: AiBinding = serde_json::from_str(&json).unwrap(); + assert_eq!(binding, deserialized); + } + + #[test] + fn test_vertex_binding_roundtrip() { + let binding = AiBinding::vertex("my-project", "us-central1"); + + let json = serde_json::to_string(&binding).unwrap(); + assert!(json.contains(r#""service":"vertex""#)); + + let deserialized: AiBinding = serde_json::from_str(&json).unwrap(); + assert_eq!(binding, deserialized); + } + + #[test] + fn test_foundry_binding_roundtrip() { + let binding = AiBinding::foundry("https://my-foundry.openai.azure.com", "my-subscription"); + + let json = serde_json::to_value(&binding).unwrap(); + let json_str = json.to_string(); + assert!(json_str.contains(r#""service":"foundry""#)); + assert!( + !json_str.contains("instantAccess"), + "foundry binding must not serialize instant_access" + ); + + let deserialized: AiBinding = serde_json::from_value(json).unwrap(); + assert_eq!(binding, deserialized); + } + + #[test] + fn test_external_binding_roundtrip() { + let binding = AiBinding::external("openai", "sk-test-key"); + + // The injected env-var JSON must match exactly what the SDK's + // `ai(name)` parser expects: service-tagged, camelCase, key inline. + let json = serde_json::to_value(&binding).unwrap(); + let json_str = json.to_string(); + assert!(json_str.contains(r#""apiKey""#), "external binding must serialize apiKey in camelCase"); + assert_eq!( + json, + serde_json::json!({ + "service": "external", + "provider": "openai", + "apiKey": "sk-test-key", + }) + ); + + let deserialized: AiBinding = + serde_json::from_value(json).expect("external binding should round-trip"); + assert_eq!(binding, deserialized); + } +} diff --git a/crates/alien-core/src/bindings/mod.rs b/crates/alien-core/src/bindings/mod.rs index 793102de1..9b44528c9 100644 --- a/crates/alien-core/src/bindings/mod.rs +++ b/crates/alien-core/src/bindings/mod.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::collections::HashMap; +mod ai; mod artifact_registry; mod build; mod container; @@ -26,6 +27,9 @@ mod storage; mod vault; mod worker; +pub use ai::{ + AiBinding, BedrockAiBinding, ExternalAiBinding, FoundryAiBinding, VertexAiBinding, +}; pub use artifact_registry::{ AcrArtifactRegistryBinding, ArtifactRegistryBinding, EcrArtifactRegistryBinding, GarArtifactRegistryBinding, LocalArtifactRegistryBinding, diff --git a/crates/alien-core/src/external_bindings.rs b/crates/alien-core/src/external_bindings.rs index a01f20868..432be3677 100644 --- a/crates/alien-core/src/external_bindings.rs +++ b/crates/alien-core/src/external_bindings.rs @@ -10,8 +10,8 @@ use alien_error::AlienError; use serde::{Deserialize, Serialize}; use crate::bindings::{ - ArtifactRegistryBinding, BindingValue, ContainerAppsEnvironmentBinding, KvBinding, - PostgresBinding, QueueBinding, StorageBinding, VaultBinding, + ArtifactRegistryBinding, BindingValue, ContainerAppsEnvironmentBinding, ExternalAiBinding, + KvBinding, PostgresBinding, QueueBinding, StorageBinding, VaultBinding, }; use crate::error::ErrorData; use crate::resource::ResourceOutputs; @@ -40,6 +40,8 @@ pub enum ExternalBinding { ContainerAppsEnvironment(ContainerAppsEnvironmentBinding), /// External Postgres binding (operator-provided / BYO database) Postgres(PostgresBinding), + /// External AI provider binding (BYO-key OpenAI/Anthropic, etc.) + Ai(ExternalAiBinding), } /// Map from resource ID to external binding. @@ -144,6 +146,20 @@ impl ExternalBindings { } } + /// Gets an AI binding for the given resource ID. + /// Returns an error if the binding exists but is not an Ai type. + pub fn get_ai(&self, id: &str) -> crate::error::Result> { + match self.0.get(id) { + Some(ExternalBinding::Ai(b)) => Ok(Some(b)), + Some(other) => Err(AlienError::new(ErrorData::ExternalBindingTypeMismatch { + resource_id: id.to_string(), + expected: "ai".to_string(), + actual: other.binding_type().to_string(), + })), + None => Ok(None), + } + } + /// Gets a container apps environment binding for the given resource ID. /// Returns an error if the binding exists but is not a ContainerAppsEnvironment type. pub fn get_container_apps_environment( @@ -191,6 +207,7 @@ impl ExternalBinding { ExternalBinding::Vault(_) => "vault", ExternalBinding::ContainerAppsEnvironment(_) => "azure_container_apps_environment", ExternalBinding::Postgres(_) => "postgres", + ExternalBinding::Ai(_) => "ai", } } @@ -241,6 +258,28 @@ impl ExternalBinding { _ => None, } } + + /// Serializes this external binding to the JSON value injected into the worker + /// environment as `ALIEN__BINDING`, matching the shape the SDK parses. + /// + /// The AI arm wraps the carried `ExternalAiBinding` in `AiBinding::External` so + /// the value keeps the `service` tag the SDK's `ai(name)` parser discriminates + /// on; a bare `ExternalAiBinding` would lack it. Every other arm serializes the + /// carried binding directly, like the runtime controllers' `get_binding_params`. + pub fn to_env_binding_value(&self) -> serde_json::Result { + match self { + ExternalBinding::Storage(b) => serde_json::to_value(b), + ExternalBinding::Queue(b) => serde_json::to_value(b), + ExternalBinding::Kv(b) => serde_json::to_value(b), + ExternalBinding::ArtifactRegistry(b) => serde_json::to_value(b), + ExternalBinding::Vault(b) => serde_json::to_value(b), + ExternalBinding::ContainerAppsEnvironment(b) => serde_json::to_value(b), + ExternalBinding::Postgres(b) => serde_json::to_value(b), + ExternalBinding::Ai(b) => { + serde_json::to_value(crate::bindings::AiBinding::External(b.clone())) + } + } + } } /// Validates that an external binding type matches the resource type. @@ -259,6 +298,7 @@ pub fn validate_binding_type( ("vault", ExternalBinding::Vault(_)) => true, ("azure_container_apps_environment", ExternalBinding::ContainerAppsEnvironment(_)) => true, ("postgres", ExternalBinding::Postgres(_)) => true, + ("ai", ExternalBinding::Ai(_)) => true, _ => false, }; @@ -303,6 +343,25 @@ mod tests { assert!(bindings.get_storage("cache").is_err()); // Wrong type } + #[test] + fn test_external_bindings_ai() { + use crate::bindings::ExternalAiBinding; + + let mut bindings = ExternalBindings::new(); + bindings.insert( + "llm", + ExternalBinding::Ai(ExternalAiBinding { + provider: "openai".to_string(), + api_key: "sk-test".into(), + }), + ); + + assert!(bindings.has("llm")); + assert_eq!(bindings.get("llm").unwrap().binding_type(), "ai"); + assert_eq!(bindings.get_ai("llm").unwrap().unwrap().provider, "openai"); + assert!(bindings.get_kv("llm").is_err()); // Wrong type + } + #[test] fn test_external_bindings_serialization() { let mut bindings = ExternalBindings::new(); diff --git a/crates/alien-core/src/heartbeat.rs b/crates/alien-core/src/heartbeat.rs index 7f35c24c8..5aad9249c 100644 --- a/crates/alien-core/src/heartbeat.rs +++ b/crates/alien-core/src/heartbeat.rs @@ -156,6 +156,8 @@ pub enum ResourceHeartbeatData { AzureContainerAppsEnvironment(AzureContainerAppsEnvironmentHeartbeatData), #[serde(rename = "azure_service_bus_namespace")] AzureServiceBusNamespace(AzureServiceBusNamespaceHeartbeatData), + #[serde(rename = "ai")] + Ai(AiHeartbeatData), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1527,6 +1529,43 @@ impl Default for PostgresHeartbeatStatus { } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(tag = "backend", rename_all = "camelCase")] +pub enum AiHeartbeatData { + AwsBedrock(AwsBedrockAiHeartbeatData), + GcpVertex(GcpVertexAiHeartbeatData), + AzureFoundry(AzureFoundryAiHeartbeatData), + External(ExternalAiHeartbeatData), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AiHeartbeatStatus { + pub health: ObservedHealth, + pub lifecycle: ProviderLifecycleState, + pub message: Option, + pub stale: bool, + pub partial: bool, + pub collection_issues: Vec, +} + +impl Default for AiHeartbeatStatus { + fn default() -> Self { + // No per-stack resource to poll, so unobserved = healthy/running; + // errors surface at inference time via the SDK. + Self { + health: ObservedHealth::Healthy, + lifecycle: ProviderLifecycleState::Running, + message: None, + stale: false, + partial: false, + collection_issues: vec![], + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] @@ -1580,6 +1619,44 @@ pub struct AzureFlexibleServerPostgresHeartbeatData { pub version: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsBedrockAiHeartbeatData { + pub status: AiHeartbeatStatus, + pub region: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpVertexAiHeartbeatData { + pub status: AiHeartbeatStatus, + pub project: String, + pub location: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureFoundryAiHeartbeatData { + pub status: AiHeartbeatStatus, + pub account_name: String, + pub endpoint: Option, + pub resource_group: Option, + pub location: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ExternalAiHeartbeatData { + pub status: AiHeartbeatStatus, + /// The BYO-key provider serving this binding (e.g. "openai"). Used on the Local + /// platform, where the app brings its own provider key instead of an ambient cloud. + pub provider: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[serde(tag = "backend", rename_all = "camelCase")] @@ -2726,4 +2803,53 @@ mod tests { assert!(kv["data"].get("summary").is_none()); assert_eq!(kv["data"]["itemCount"], json!(null)); } + + #[test] + fn ai_heartbeat_serializes_stable_tags() { + let aws = serde_json::to_value(ResourceHeartbeatData::Ai(AiHeartbeatData::AwsBedrock( + AwsBedrockAiHeartbeatData { + status: AiHeartbeatStatus::default(), + region: "us-east-1".to_string(), + }, + ))) + .unwrap(); + let gcp = + serde_json::to_value(ResourceHeartbeatData::Ai(AiHeartbeatData::GcpVertex( + GcpVertexAiHeartbeatData { + status: AiHeartbeatStatus::default(), + project: "my-project".to_string(), + location: "us-central1".to_string(), + }, + ))) + .unwrap(); + let azure = + serde_json::to_value(ResourceHeartbeatData::Ai(AiHeartbeatData::AzureFoundry( + AzureFoundryAiHeartbeatData { + status: AiHeartbeatStatus::default(), + account_name: "my-ai-account".to_string(), + endpoint: Some("https://my-ai-account.openai.azure.com/".to_string()), + resource_group: Some("my-rg".to_string()), + location: Some("eastus".to_string()), + }, + ))) + .unwrap(); + + assert_eq!(aws["resourceType"], "ai"); + assert_eq!(aws["data"]["backend"], "awsBedrock"); + assert_eq!(aws["data"]["region"], "us-east-1"); + assert!(aws["data"].get("summary").is_none()); + + assert_eq!(gcp["resourceType"], "ai"); + assert_eq!(gcp["data"]["backend"], "gcpVertex"); + assert_eq!(gcp["data"]["project"], "my-project"); + assert_eq!(gcp["data"]["location"], "us-central1"); + + assert_eq!(azure["resourceType"], "ai"); + assert_eq!(azure["data"]["backend"], "azureFoundry"); + assert_eq!(azure["data"]["accountName"], "my-ai-account"); + assert_eq!( + azure["data"]["endpoint"], + "https://my-ai-account.openai.azure.com/" + ); + } } diff --git a/crates/alien-core/src/import/data/aws/ai.rs b/crates/alien-core/src/import/data/aws/ai.rs new file mode 100644 index 000000000..e00354ef5 --- /dev/null +++ b/crates/alien-core/src/import/data/aws/ai.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +/// AWS AI ImportData. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct AwsAiImportData { + /// AWS region where Bedrock is accessed. + pub region: String, +} diff --git a/crates/alien-core/src/import/data/aws/mod.rs b/crates/alien-core/src/import/data/aws/mod.rs index 020715aa3..b6cff9817 100644 --- a/crates/alien-core/src/import/data/aws/mod.rs +++ b/crates/alien-core/src/import/data/aws/mod.rs @@ -1,3 +1,4 @@ +pub mod ai; pub mod artifact_registry; pub mod build; pub mod compute_cluster; @@ -13,6 +14,7 @@ pub mod storage; pub mod vault; pub mod worker; +pub use ai::*; pub use artifact_registry::*; pub use build::*; pub use compute_cluster::*; diff --git a/crates/alien-core/src/import/data/azure/ai.rs b/crates/alien-core/src/import/data/azure/ai.rs new file mode 100644 index 000000000..eefa5286d --- /dev/null +++ b/crates/alien-core/src/import/data/azure/ai.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +/// Azure AI (AIServices) ImportData. +/// +/// Carries the account name, endpoint, resource group, and location from an +/// externally-created AIServices account directly into the controller's ready +/// state so that heartbeat ticks and binding-param serialization work without +/// a cloud round-trip. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct AzureAiImportData { + /// Name of the Azure CognitiveServices / AIServices account. + pub account_name: String, + /// The endpoint URL of the AIServices account. + pub endpoint: String, + /// Azure resource group containing the account. + pub resource_group: String, + /// Azure region where the account lives (e.g. "eastus"). + pub location: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The Azure AI Terraform emitter writes the import ref with camelCase keys + /// (`crates/alien-terraform/src/emitters/azure/ai.rs`); it also emits an extra + /// `subscriptionId` this struct does not read. This pins the four consumed keys so a + /// future rename can't silently break the frozen/Terraform import round-trip. + #[test] + fn deserializes_the_emitter_import_ref_keys() { + let json = serde_json::json!({ + "subscriptionId": "sub-ignored", + "resourceGroup": "my-rg", + "accountName": "my-account", + "endpoint": "https://my-account.openai.azure.com/", + "location": "eastus", + }); + let data: AzureAiImportData = + serde_json::from_value(json).expect("the emitter's import-ref keys must deserialize"); + assert_eq!(data.account_name, "my-account"); + assert_eq!(data.endpoint, "https://my-account.openai.azure.com/"); + assert_eq!(data.resource_group, "my-rg"); + assert_eq!(data.location, "eastus"); + } +} diff --git a/crates/alien-core/src/import/data/azure/mod.rs b/crates/alien-core/src/import/data/azure/mod.rs index 588c78c9a..d21075016 100644 --- a/crates/alien-core/src/import/data/azure/mod.rs +++ b/crates/alien-core/src/import/data/azure/mod.rs @@ -1,3 +1,4 @@ +pub mod ai; pub mod artifact_registry; pub mod build; pub mod compute_cluster; @@ -16,6 +17,7 @@ pub mod storage_account; pub mod vault; pub mod worker; +pub use ai::*; pub use artifact_registry::*; pub use build::*; pub use compute_cluster::*; diff --git a/crates/alien-core/src/import/data/gcp/ai.rs b/crates/alien-core/src/import/data/gcp/ai.rs new file mode 100644 index 000000000..58a557c68 --- /dev/null +++ b/crates/alien-core/src/import/data/gcp/ai.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +/// GCP AI (Vertex AI) ImportData. +/// +/// The `project_id` and `location` fields identify the Vertex AI endpoint and +/// are carried directly into the controller's ready state so that heartbeat +/// ticks and binding-param serialization work without a cloud round-trip. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct GcpAiImportData { + /// GCP project ID that owns the Vertex AI endpoint. + pub project_id: String, + /// GCP region (location) of the Vertex AI endpoint. + pub location: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The GCP AI Terraform emitter writes the import ref with key `projectId` + /// (`crates/alien-terraform/src/emitters/gcp/ai.rs`), matching every other GCP + /// resource. This pins the serde key so a frozen/Terraform import round-trip does not + /// fail with `missing field project`. + #[test] + fn deserializes_the_emitter_import_ref_key() { + let json = serde_json::json!({ "projectId": "my-project", "location": "us-central1" }); + let data: GcpAiImportData = + serde_json::from_value(json).expect("emitter's projectId key must deserialize"); + assert_eq!(data.project_id, "my-project"); + assert_eq!(data.location, "us-central1"); + } +} diff --git a/crates/alien-core/src/import/data/gcp/mod.rs b/crates/alien-core/src/import/data/gcp/mod.rs index cf77c95b8..663382061 100644 --- a/crates/alien-core/src/import/data/gcp/mod.rs +++ b/crates/alien-core/src/import/data/gcp/mod.rs @@ -1,3 +1,4 @@ +pub mod ai; pub mod artifact_registry; pub mod build; pub mod compute_cluster; @@ -12,6 +13,7 @@ pub mod storage; pub mod vault; pub mod worker; +pub use ai::*; pub use artifact_registry::*; pub use build::*; pub use compute_cluster::*; diff --git a/crates/alien-core/src/import/data/mod.rs b/crates/alien-core/src/import/data/mod.rs index 4dabb9a9a..2885f7ef2 100644 --- a/crates/alien-core/src/import/data/mod.rs +++ b/crates/alien-core/src/import/data/mod.rs @@ -8,23 +8,23 @@ pub mod gcp; pub mod kubernetes_cluster; pub use aws::{ - AwsArtifactRegistryImportData, AwsBuildImportData, AwsComputeClusterImportData, + AwsAiImportData, AwsArtifactRegistryImportData, AwsBuildImportData, AwsComputeClusterImportData, AwsEmailDkimTokenImportData, AwsEmailDomainImportData, AwsEmailImportData, AwsKvImportData, AwsNetworkImportData, AwsOpenSearchImportData, AwsPostgresImportData, AwsQueueImportData, AwsRemoteStackManagementImportData, AwsServiceAccountImportData, AwsStorageImportData, AwsVaultImportData, AwsWorkerImportData, }; pub use azure::{ - AzureArtifactRegistryImportData, AzureBuildImportData, AzureComputeClusterImportData, - AzureContainerAppsEnvironmentImportData, AzureFlexibleServerPostgresImportData, - AzureKvImportData, AzureNetworkImportData, AzureQueueImportData, - AzureRemoteStackManagementImportData, AzureResourceGroupImportData, + AzureAiImportData, AzureArtifactRegistryImportData, AzureBuildImportData, + AzureComputeClusterImportData, AzureContainerAppsEnvironmentImportData, + AzureFlexibleServerPostgresImportData, AzureKvImportData, AzureNetworkImportData, + AzureQueueImportData, AzureRemoteStackManagementImportData, AzureResourceGroupImportData, AzureServiceAccountImportData, AzureServiceActivationImportData, AzureServiceBusNamespaceImportData, AzureStorageAccountImportData, AzureStorageImportData, AzureVaultImportData, AzureWorkerImportData, }; pub use gcp::{ - GcpArtifactRegistryImportData, GcpBuildImportData, GcpComputeClusterImportData, + GcpAiImportData, GcpArtifactRegistryImportData, GcpBuildImportData, GcpComputeClusterImportData, GcpKvImportData, GcpNetworkImportData, GcpPostgresImportData, GcpQueueImportData, GcpRemoteStackManagementImportData, GcpServiceAccountImportData, GcpServiceActivationImportData, GcpStorageImportData, GcpVaultImportData, GcpWorkerImportData, diff --git a/crates/alien-core/src/lib.rs b/crates/alien-core/src/lib.rs index 871179a3d..fc2b29762 100644 --- a/crates/alien-core/src/lib.rs +++ b/crates/alien-core/src/lib.rs @@ -60,6 +60,7 @@ pub use stack_settings::*; mod public_urls; pub use public_urls::*; +pub mod ai_catalog; pub mod bindings; pub use bindings::*; diff --git a/crates/alien-core/src/ownership.rs b/crates/alien-core/src/ownership.rs index c894d5d08..557e4641e 100644 --- a/crates/alien-core/src/ownership.rs +++ b/crates/alien-core/src/ownership.rs @@ -96,7 +96,7 @@ pub fn ownership_policy_for_resource_type(resource_type: &str) -> ResourceOwners // Durable search state, setup-owned only: there is no runtime // controller that could provision or replace the collection. "experimental/aws-opensearch" => frozen_only(), - "storage" | "queue" | "kv" | "vault" | "postgres" => user_choice(), + "storage" | "queue" | "kv" | "vault" | "postgres" | "ai" => user_choice(), _ => user_choice(), } } @@ -175,7 +175,7 @@ mod tests { #[test] fn data_resources_can_be_frozen_or_live() { - for resource_type in ["storage", "queue", "kv", "vault", "postgres"] { + for resource_type in ["storage", "queue", "kv", "vault", "postgres", "ai"] { let policy = ownership_policy_for_resource_type(resource_type); assert_eq!(policy.default_lifecycle(), ResourceLifecycle::Frozen); assert!(policy.allows_lifecycle(ResourceLifecycle::Frozen)); diff --git a/crates/alien-core/src/resource.rs b/crates/alien-core/src/resource.rs index b171ce35e..4f2f309ab 100644 --- a/crates/alien-core/src/resource.rs +++ b/crates/alien-core/src/resource.rs @@ -214,6 +214,10 @@ impl<'de> Deserialize<'de> for Resource { serde_json::from_value::(value) .map_err(serde::de::Error::custom)?, ), + "ai" => Box::new( + serde_json::from_value::(value) + .map_err(serde::de::Error::custom)?, + ), "network" => Box::new( serde_json::from_value::(value) .map_err(serde::de::Error::custom)?, @@ -273,6 +277,7 @@ impl<'de> Deserialize<'de> for Resource { "email", "kv", "postgres", + "ai", "network", "build", "service-account", @@ -560,6 +565,10 @@ impl<'de> Deserialize<'de> for ResourceOutputs { serde_json::from_value::(value) .map_err(serde::de::Error::custom)?, ), + "ai" => Box::new( + serde_json::from_value::(value) + .map_err(serde::de::Error::custom)?, + ), "network" => Box::new( serde_json::from_value::(value) .map_err(serde::de::Error::custom)?, @@ -624,6 +633,7 @@ impl<'de> Deserialize<'de> for ResourceOutputs { "email", "kv", "postgres", + "ai", "network", "build", "service-account", diff --git a/crates/alien-core/src/resources/ai.rs b/crates/alien-core/src/resources/ai.rs new file mode 100644 index 000000000..a2180c94c --- /dev/null +++ b/crates/alien-core/src/resources/ai.rs @@ -0,0 +1,172 @@ +use crate::error::{ErrorData, Result}; +use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef}; +use crate::ResourceType; +use alien_error::AlienError; +use bon::Builder; +use serde::{Deserialize, Serialize}; +use std::any::Any; +use std::fmt::Debug; + +/// Represents an AI Gateway resource that provides a unified interface to +/// managed AI inference services across cloud providers. +/// +/// BYO-key external providers (OpenAI/Anthropic) are NOT declared here. Like any +/// other BYO infrastructure (e.g. external Redis for `kv`), an external AI +/// provider is supplied at deploy time as an `ExternalBinding::Ai` in the +/// stack's external-bindings map; the executor then skips the cloud controller. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[builder(start_fn = new)] +pub struct Ai { + /// Identifier for the AI resource. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]). + /// Maximum 64 characters. + #[builder(start_fn)] + pub id: String, +} + +impl Ai { + /// The resource type identifier for AI Gateway + pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("ai"); + + /// Returns the AI resource's unique identifier. + pub fn id(&self) -> &str { + &self.id + } +} + +/// Outputs generated by a successfully provisioned AI Gateway resource. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AiOutputs { + /// The AI provider name (e.g., "bedrock", "vertex", "foundry", "external"). + pub provider: String, + /// The provider endpoint URL, if applicable. + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + /// The provider account or project identifier, if applicable. + #[serde(skip_serializing_if = "Option::is_none")] + pub account: Option, +} + +impl ResourceOutputsDefinition for AiOutputs { + fn get_resource_type(&self) -> ResourceType { + Ai::RESOURCE_TYPE.clone() + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } + + fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn to_json_value(&self) -> serde_json::Result { + serde_json::to_value(self) + } +} + +impl ResourceDefinition for Ai { + fn get_resource_type(&self) -> ResourceType { + Self::RESOURCE_TYPE + } + + fn id(&self) -> &str { + &self.id + } + + fn get_dependencies(&self) -> Vec { + Vec::new() + } + + fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> { + let new_ai = new_config.as_any().downcast_ref::().ok_or_else(|| { + AlienError::new(ErrorData::UnexpectedResourceType { + resource_id: self.id.clone(), + expected: Self::RESOURCE_TYPE, + actual: new_config.get_resource_type(), + }) + })?; + + if self.id != new_ai.id { + return Err(AlienError::new(ErrorData::InvalidResourceUpdate { + resource_id: self.id.clone(), + reason: "the 'id' field is immutable".to_string(), + })); + } + Ok(()) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } + + fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn to_json_value(&self) -> serde_json::Result { + serde_json::to_value(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ai_builder() { + let ai = Ai::new("llm".to_string()).build(); + assert_eq!(ai.id, "llm"); + } + + #[test] + fn test_ai_resource_type() { + assert_eq!(Ai::RESOURCE_TYPE.as_ref(), "ai"); + } + + #[test] + fn test_ai_resource_definition() { + let ai = Ai::new("test-ai".to_string()).build(); + assert_eq!(ai.get_resource_type(), Ai::RESOURCE_TYPE); + assert_eq!(ResourceDefinition::id(&ai), "test-ai"); + assert!(ai.get_dependencies().is_empty()); + } + + #[test] + fn test_ai_validate_update() { + let original = Ai::new("test-ai".to_string()).build(); + let valid_update = Ai::new("test-ai".to_string()).build(); + let invalid_update = Ai::new("different-ai".to_string()).build(); + + assert!(original.validate_update(&valid_update).is_ok()); + assert!(original.validate_update(&invalid_update).is_err()); + } + + #[test] + fn test_ai_outputs_serialization() { + let outputs = AiOutputs { + provider: "bedrock".to_string(), + endpoint: Some("https://bedrock-runtime.us-east-1.amazonaws.com".to_string()), + account: None, + }; + + let json = serde_json::to_string(&outputs).unwrap(); + let deserialized: AiOutputs = serde_json::from_str(&json).unwrap(); + assert_eq!(outputs, deserialized); + } +} diff --git a/crates/alien-core/src/resources/mod.rs b/crates/alien-core/src/resources/mod.rs index 11faf021c..cf70516d6 100644 --- a/crates/alien-core/src/resources/mod.rs +++ b/crates/alien-core/src/resources/mod.rs @@ -60,6 +60,9 @@ pub use vault::*; mod kv; pub use kv::*; +mod ai; +pub use ai::*; + mod network; pub use network::*; diff --git a/crates/alien-deployment/src/initial_setup.rs b/crates/alien-deployment/src/initial_setup.rs index 55d66c319..2c7bd7071 100644 --- a/crates/alien-deployment/src/initial_setup.rs +++ b/crates/alien-deployment/src/initial_setup.rs @@ -4,7 +4,7 @@ use crate::{ use alien_core::{ResourceLifecycle, ResourceStatus, Stack, StackState, StackStatus}; use alien_error::{AlienError, Context}; use alien_infra::StackExecutor; -use tracing::{debug, info}; +use tracing::{debug, error, info}; /// Handle InitialSetup status (deploy setup-owned Frozen resources) /// @@ -177,6 +177,15 @@ pub async fn handle_initial_setup( | alien_core::ResourceStatus::RefreshFailed ) }) + .inspect(|r| { + error!( + resource_id = %r.config.id(), + resource_type = %r.resource_type, + status = ?r.status, + error = ?r.error, + "InitialSetup failure: resource in failed state" + ); + }) .map(|r| (r.config.id().to_string(), r.resource_type.clone())) .collect(); diff --git a/crates/alien-gateway/Cargo.toml b/crates/alien-gateway/Cargo.toml new file mode 100644 index 000000000..b8a5465c3 --- /dev/null +++ b/crates/alien-gateway/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "alien-gateway" +version.workspace = true +edition.workspace = true +authors.workspace = true +description.workspace = true +license-file.workspace = true + +[lib] +crate-type = ["rlib"] + +[[bin]] +name = "alien-ai-gateway" +path = "src/bin/alien-ai-gateway.rs" + +[dependencies] +alien-core = { workspace = true } +alien-bindings = { workspace = true, default-features = false, features = ["aws", "gcp", "azure"] } +alien-error = { workspace = true, features = ["axum"] } +tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] } +axum = { workspace = true, features = ["http1", "tokio", "json"] } +reqwest = { workspace = true, features = ["json", "stream", "blocking", "rustls-tls-webpki-roots"] } +futures = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +base64 = { workspace = true } +tracing = { workspace = true } +aws-config = { workspace = true } +aws-sigv4 = { workspace = true } +aws-credential-types = { workspace = true } +aws-smithy-eventstream = { workspace = true } +aws-smithy-types = { workspace = true } +http = { workspace = true } + +[dev-dependencies] +httpmock = { workspace = true } +temp-env = { workspace = true } diff --git a/crates/alien-gateway/src/bin/alien-ai-gateway.rs b/crates/alien-gateway/src/bin/alien-ai-gateway.rs new file mode 100644 index 000000000..b8116c871 --- /dev/null +++ b/crates/alien-gateway/src/bin/alien-ai-gateway.rs @@ -0,0 +1,98 @@ +//! Container AI-gateway launcher. A thin bootstrap that hosts the embedded gateway +//! for a workload whose image is not built through alien-runtime (BYO Docker +//! containers). It never supervises the app: it starts the gateway, injects +//! ALIEN_AI_GATEWAY_URL, and `exec`s the app so the app runs as the main process. +//! +//! Modes: +//! alien-ai-gateway --gateway-serve run the gateway forever (fixed port) +//! alien-ai-gateway -- [args...] bootstrap, then exec + +use std::net::{Ipv4Addr, SocketAddr}; +use std::os::unix::process::CommandExt; +use std::process::{Command, Stdio}; + +const DEFAULT_PORT: u16 = 9008; +const PORT_ENV: &str = "ALIEN_AI_GATEWAY_PORT"; +const URL_ENV: &str = "ALIEN_AI_GATEWAY_URL"; +const SERVE_FLAG: &str = "--gateway-serve"; + +fn port() -> u16 { + match std::env::var(PORT_ENV) { + Err(_) => DEFAULT_PORT, + // Set but unparseable: fail loud rather than silently binding a different port. + Ok(v) => v + .parse() + .unwrap_or_else(|_| die(&format!("{PORT_ENV}='{v}' is not a valid port"))), + } +} + +fn die(msg: &str) -> ! { + eprintln!("alien-ai-gateway: {msg}"); + std::process::exit(1); +} + +fn main() { + let args: Vec = std::env::args().collect(); + + if args.get(1).map(String::as_str) == Some(SERVE_FLAG) { + serve_forever(); + } + + let app_cmd = match args.iter().position(|a| a == "--") { + Some(i) if i + 1 < args.len() => &args[i + 1..], + _ => die("usage: alien-ai-gateway -- [args...]"), + }; + + let bindings = alien_gateway::bindings_from_env() + .unwrap_or_else(|e| die(&format!("could not read the AI bindings: {e}"))); + if bindings.is_empty() { + // No ambient AI binding (or BYO-key only): run the app directly, zero overhead. + exec_app(app_cmd); + } + + // exec_app replaces this process image, so the gateway can't run in-process — it + // wouldn't survive the exec. Spawn a separate copy of ourselves to serve it, wait + // until it's ready, then exec the app. + let self_exe = + std::env::current_exe().unwrap_or_else(|e| die(&format!("cannot resolve own path: {e}"))); + let mut child = Command::new(self_exe) + .arg(SERVE_FLAG) + .stdin(Stdio::null()) + .spawn() + .unwrap_or_else(|e| die(&format!("failed to start gateway child: {e}"))); + + let base = format!("http://127.0.0.1:{}", port()); + if !alien_gateway::wait_until_ready_blocking(&base) { + // Surface the child's own startup failure (e.g. an unavailable ambient + // credential) rather than only a generic readiness timeout. + if let Ok(Some(status)) = child.try_wait() { + die(&format!("gateway process exited before ready ({status})")); + } + die(&format!("gateway at {base} did not become ready")); + } + std::env::set_var(URL_ENV, &base); + exec_app(app_cmd); +} + +fn serve_forever() -> ! { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap_or_else(|e| die(&format!("tokio runtime: {e}"))); + rt.block_on(async { + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port())); + let bindings = alien_gateway::bindings_from_env() + .unwrap_or_else(|e| die(&format!("could not read the AI bindings: {e}"))); + let _handle = alien_gateway::start_gateway_on(bindings, addr) + .await + .unwrap_or_else(|e| die(&format!("gateway failed to start: {e}"))); + // Hold the process (and the server task) open for the container lifetime. + std::future::pending::<()>().await; + }); + unreachable!() +} + +fn exec_app(app_cmd: &[String]) -> ! { + let err = Command::new(&app_cmd[0]).args(&app_cmd[1..]).exec(); + die(&format!("failed to exec {}: {err}", app_cmd[0])); +} diff --git a/crates/alien-gateway/src/config.rs b/crates/alien-gateway/src/config.rs new file mode 100644 index 000000000..79baa0459 --- /dev/null +++ b/crates/alien-gateway/src/config.rs @@ -0,0 +1,366 @@ +//! Build the gateway's routing table from the bindings the runtime injects. +//! +//! Each `ai` resource the workload links is injected as `ALIEN__BINDING` JSON. +//! That env-var namespace is shared with every other resource type, so we parse each +//! value as an `AiBinding` and keep only the ones that match (a non-AI binding carries +//! no matching AI `service` tag, so it fails to parse). `resolve_route` then attaches +//! the cloud's ambient credential to produce a route the proxy can serve. + +use std::collections::HashMap; +use std::sync::Arc; + +use alien_bindings::provider::LazyEnvBindingsProvider; +use alien_bindings::BindingsProvider; +use alien_core::bindings::AiBinding; +use alien_core::{ + AzureCredentials, ClientConfig, GcpCredentials, Platform, ENV_ALIEN_DEPLOYMENT_TOKEN, + ENV_ALIEN_DEPLOYMENT_TYPE, ENV_ALIEN_MANAGER_URL, +}; +use alien_error::{AlienError, Context, IntoAlienError}; + +use crate::creds::{AmbientCred, AwsSigV4Cred, BearerTokenCred}; +use crate::error::{ErrorData, Result}; +use crate::{GatewayBinding, GatewayRoute}; + +/// The shared workload credential resolver used for the mint-gated (runtime-less) path. +pub type Managed = Arc; + +/// Build the shared credential resolver once, only when the runtime-less mint gate is present +/// (`ALIEN_MANAGER_URL` + `ALIEN_DEPLOYMENT_TOKEN`). The gateway holds it for the process so +/// every request re-resolves through it and short-lived minted credentials refresh before +/// they expire. Absent (a projected-identity workload) → `None`, and routes fall back to the +/// self-refreshing native credential path. +pub fn managed_provider() -> Result> { + let env: HashMap = std::env::vars().collect(); + let mint_gate = env.contains_key(ENV_ALIEN_MANAGER_URL) && env.contains_key(ENV_ALIEN_DEPLOYMENT_TOKEN); + if !mint_gate { + return Ok(None); + } + // Past the gate, `from_env_lazy` only fails on an unusable deployment type or malformed + // binding JSON. Swallowing that would sign upstream calls with whatever ambient identity + // the host happens to carry, instead of the workload's. + let provider = BindingsProvider::from_env_lazy(env).context(ErrorData::Other { + message: "the workload credential resolver could not be built".to_string(), + })?; + Ok(Some(Arc::new(provider))) +} + +/// AAD audience for an Azure AI Services / Foundry account token. +const AZURE_AI_AUDIENCE: &str = "https://cognitiveservices.azure.com"; + +/// The workload's resolved cloud credentials, from the runtime-less credential resolver: the +/// native/projected identity when present, else Alien-minted short-lived credentials (the +/// resolver in `alien-bindings` mints against the manager when `ALIEN_MANAGER_URL` + +/// `ALIEN_DEPLOYMENT_TOKEN` are set). `None` when neither is available (e.g. a projected +/// instance-role container that carries no explicit config), so the caller falls back to the +/// SDK default chain / metadata server, which resolve the same projected identity. +async fn workload_client_config() -> Result> { + let env: HashMap = std::env::vars().collect(); + // No deployment type: the gateway is running outside an Alien workload (local dev, the + // standalone launcher), so there is no resolved identity and the caller falls back to the + // SDK default chain / metadata server. Any other failure is real. + if !env.contains_key(ENV_ALIEN_DEPLOYMENT_TYPE) { + return Ok(None); + } + let lazy = BindingsProvider::from_env_lazy(env).context(ErrorData::Other { + message: "the workload credential resolver could not be built".to_string(), + })?; + let provider = lazy.provider().await.context(ErrorData::AmbientCredentialUnavailable { + message: "the workload credential resolver failed".to_string(), + })?; + Ok(Some(provider.client_config().clone())) +} + +/// The workload's GCP access token when the resolver produced a ready OAuth2 token (the minted / +/// explicit case). `None` for any other variant, so the caller falls back to the metadata server. +async fn gcp_access_token() -> Result> { + let Some(config) = workload_client_config().await? else { + return Ok(None); + }; + Ok(config.gcp_config().and_then(|g| match &g.credentials { + GcpCredentials::AccessToken { token } => Some(token.clone()), + _ => None, + })) +} + +/// The workload's Azure access token when the resolver produced a ready bearer token; `None` +/// otherwise, so the caller falls back to Azure IMDS (the projected-identity case). +async fn azure_access_token() -> Result> { + let Some(config) = workload_client_config().await? else { + return Ok(None); + }; + Ok(config.azure_config().and_then(|a| match &a.credentials { + AzureCredentials::AccessToken { token } => Some(token.clone()), + _ => None, + })) +} + +/// Parse each `ALIEN__BINDING` env var into a `GatewayBinding`, skipping non-AI +/// bindings and the External BYO-key variant. +pub fn bindings_from_env() -> Result> { + bindings_from_pairs(std::env::vars()) +} + +/// Like [`bindings_from_env`], but over an explicit env map instead of `std::env::vars`. +pub fn bindings_from_env_map(env: &HashMap) -> Result> { + bindings_from_pairs(env.iter().map(|(k, v)| (k.clone(), v.clone()))) +} + +/// The `service` tags `AiBinding` deserializes. A binding carrying any other tag belongs to +/// another resource type sharing the `ALIEN__BINDING` namespace. +const AI_SERVICE_TAGS: [&str; 4] = ["bedrock", "vertex", "foundry", "external"]; + +#[derive(serde::Deserialize)] +struct ServiceTag { + service: String, +} + +/// The binding's canonical name — the same decode `alien-bindings` applies, so the gateway's +/// route keys match the resolver's binding-map keys and a caller can append the resource's +/// own id (`//v1`). +fn canonical_binding_name(env_key_name: &str) -> String { + env_key_name.to_lowercase().replace('_', "-") +} + +fn bindings_from_pairs( + pairs: impl Iterator, +) -> Result> { + let mut bindings = Vec::new(); + for (key, value) in pairs { + let Some(name) = key.strip_prefix("ALIEN_").and_then(|k| k.strip_suffix("_BINDING")) else { + continue; + }; + // Not an AI binding: another resource type in the shared namespace. + let Ok(tag) = serde_json::from_str::(&value) else { + continue; + }; + if !AI_SERVICE_TAGS.contains(&tag.service.as_str()) { + continue; + } + let name = canonical_binding_name(name); + // Ours, but unparseable. Skipping would drop the route and leave the app calling a + // gateway that serves nothing, so surface it here. + let binding: AiBinding = serde_json::from_str(&value).into_alien_error().context( + ErrorData::BindingConfigInvalid { + binding: name.clone(), + message: format!("the '{}' binding could not be parsed", tag.service), + }, + )?; + if let Some(binding) = gateway_binding(&name, binding) { + bindings.push(binding); + } + } + Ok(bindings) +} + +/// Map a parsed `AiBinding` to a `GatewayBinding`, or `None` for variants the gateway +/// does not serve. The binding name is the canonical path segment (lowercased, as the +/// env-var key encodes it). +fn gateway_binding(name: &str, binding: AiBinding) -> Option { + match binding { + AiBinding::Bedrock(b) => Some(GatewayBinding { + name: name.to_string(), + cloud: Platform::Aws, + region: Some(b.region), + project: None, + azure_endpoint: None, + }), + AiBinding::Vertex(b) => Some(GatewayBinding { + name: name.to_string(), + cloud: Platform::Gcp, + region: Some(b.location), + project: Some(b.project), + azure_endpoint: None, + }), + AiBinding::Foundry(b) => Some(GatewayBinding { + name: name.to_string(), + cloud: Platform::Azure, + region: None, + project: None, + azure_endpoint: Some(b.endpoint), + }), + // External is a BYO-key provider, not an ambient-managed cloud — not served here. + AiBinding::External(_) => None, + } +} + +/// Attach the binding's cloud ambient credential, producing a route the proxy serves. +pub async fn resolve_route(binding: GatewayBinding, managed: Option<&Managed>) -> Result { + // The gateway authorizes upstream calls with the workload's own identity. Both paths + // handle either shape the resolver can select: minted short-lived credentials, which each + // request re-resolves so they refresh before expiry, or the workload's projected identity, + // which the SDK default chain / metadata server resolves and self-refreshes. An explicitly + // supplied token is used as-is — it carries no refresh clock. + let cred = match binding.cloud { + Platform::Aws => { + let region = binding.region.clone().ok_or_else(|| { + AlienError::new(ErrorData::BindingConfigInvalid { + binding: binding.name.clone(), + message: "an AWS binding needs a region".to_string(), + }) + })?; + match managed { + Some(p) => AmbientCred::Aws(AwsSigV4Cred::managed(region, p.clone())), + None => match workload_client_config().await? { + Some(config) if config.aws_config().is_some() => { + AmbientCred::Aws(AwsSigV4Cred::from_client_config(region, &config).await?) + } + _ => AmbientCred::Aws(AwsSigV4Cred::new(region).await?), + }, + } + } + Platform::Gcp => match managed { + Some(p) => AmbientCred::Bearer(BearerTokenCred::managed_gcp(p.clone())), + None => match gcp_access_token().await? { + Some(token) => AmbientCred::Bearer(BearerTokenCred::static_token(token)), + None => AmbientCred::Bearer(BearerTokenCred::gcp()), + }, + }, + Platform::Azure => match managed { + Some(p) => AmbientCred::Bearer(BearerTokenCred::managed_azure(p.clone(), AZURE_AI_AUDIENCE)), + None => match azure_access_token().await? { + Some(token) => AmbientCred::Bearer(BearerTokenCred::static_token(token)), + None => AmbientCred::Bearer(BearerTokenCred::azure(AZURE_AI_AUDIENCE)), + }, + }, + other => { + return Err(AlienError::new(ErrorData::BindingConfigInvalid { + binding: binding.name.clone(), + message: format!("the AI gateway does not serve {other:?} bindings"), + })) + } + }; + + Ok(GatewayRoute { + name: binding.name, + cloud: binding.cloud, + region: binding.region, + project: binding.project, + azure_endpoint: binding.azure_endpoint, + cred, + upstream_base_override: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_each_managed_binding_to_its_cloud() { + let aws = gateway_binding("llm", AiBinding::bedrock("us-east-2")).unwrap(); + assert_eq!(aws.cloud, Platform::Aws); + assert_eq!(aws.region.as_deref(), Some("us-east-2")); + + let gcp = gateway_binding("llm", AiBinding::vertex("proj", "us-central1")).unwrap(); + assert_eq!(gcp.cloud, Platform::Gcp); + assert_eq!(gcp.region.as_deref(), Some("us-central1")); + assert_eq!(gcp.project.as_deref(), Some("proj")); + + let azure = + gateway_binding("llm", AiBinding::foundry("https://x.openai.azure.com/", "acct")).unwrap(); + assert_eq!(azure.cloud, Platform::Azure); + assert_eq!(azure.azure_endpoint.as_deref(), Some("https://x.openai.azure.com/")); + } + + #[test] + fn skips_external_byo_key_binding() { + assert!(gateway_binding("llm", AiBinding::external("openai", "sk-x")).is_none()); + } + + #[test] + fn parses_an_ai_binding_from_the_env_var() { + temp_env::with_var( + "ALIEN_LLM_BINDING", + Some(r#"{"service":"foundry","endpoint":"https://x.openai.azure.com/","account":"x"}"#), + || { + let binding = bindings_from_env() + .expect("the env holds a valid AI binding") + .into_iter() + .find(|b| b.name == "llm") + .expect("the llm binding should be parsed from the env"); + assert_eq!(binding.cloud, Platform::Azure); + assert_eq!(binding.azure_endpoint.as_deref(), Some("https://x.openai.azure.com/")); + }, + ); + } + + /// The route key is the resource's own id, so a caller appends `//v1` without + /// knowing how the env var encoded it. + #[test] + fn the_route_key_is_the_canonical_binding_name() { + temp_env::with_var( + "ALIEN_MY_LLM_BINDING", + Some(r#"{"service":"bedrock","region":"us-east-2"}"#), + || { + let bindings = bindings_from_env().expect("the env holds a valid AI binding"); + assert_eq!(bindings.iter().map(|b| b.name.as_str()).collect::>(), ["my-llm"]); + }, + ); + } + + #[test] + fn ignores_non_ai_bindings_in_the_shared_namespace() { + temp_env::with_var( + "ALIEN_DB_BINDING", + Some(r#"{"connectionUrl":"postgres://localhost/db"}"#), + || { + let bindings = bindings_from_env().expect("a non-AI binding is not an error"); + assert!( + !bindings.iter().any(|b| b.name == "db"), + "a non-AI binding must not be picked up by the gateway" + ); + }, + ); + } + + /// Another resource type's binding carries its own `service` tag; that is not ours. + #[test] + fn ignores_a_non_ai_service_tag() { + temp_env::with_var( + "ALIEN_DB_BINDING", + Some(r#"{"service":"aurora","clusterEndpoint":"x","port":5432}"#), + || { + let bindings = bindings_from_env().expect("a non-AI service tag is not an error"); + assert!(bindings.is_empty(), "a postgres binding must not be picked up"); + }, + ); + } + + /// Skipping a malformed AI binding would drop its route and leave the app calling a + /// gateway that serves nothing, so it must fail loudly instead. + #[test] + fn a_malformed_ai_binding_is_an_error() { + temp_env::with_var("ALIEN_LLM_BINDING", Some(r#"{"service":"bedrock"}"#), || { + let err = bindings_from_env().expect_err("a bedrock binding with no region must fail"); + assert_eq!(err.code, "GATEWAY_BINDING_CONFIG_INVALID"); + }); + } + + /// `AI_SERVICE_TAGS` is hand-maintained, so a new `AiBinding` variant added without + /// updating it would be silently dropped as "not an AI binding". Pin the two together: + /// every variant's serialized `service` tag must be listed, and nothing extra. + #[test] + fn ai_service_tags_match_the_binding_variants() { + let tag_of = |b: &AiBinding| { + serde_json::to_value(b).unwrap()["service"] + .as_str() + .unwrap() + .to_string() + }; + let mut from_variants = [ + tag_of(&AiBinding::bedrock("us-east-1")), + tag_of(&AiBinding::vertex("p", "us-central1")), + tag_of(&AiBinding::foundry("https://x", "a")), + tag_of(&AiBinding::external("openai", "sk-x")), + ]; + from_variants.sort_unstable(); + let mut declared = AI_SERVICE_TAGS.map(str::to_string); + declared.sort_unstable(); + assert_eq!( + from_variants.as_slice(), + declared.as_slice(), + "AI_SERVICE_TAGS drifted from AiBinding's variants; update the array" + ); + } +} diff --git a/crates/alien-gateway/src/creds.rs b/crates/alien-gateway/src/creds.rs new file mode 100644 index 000000000..7e1a0fe44 --- /dev/null +++ b/crates/alien-gateway/src/creds.rs @@ -0,0 +1,515 @@ +//! Per-cloud ambient credential injection. +//! +//! Each provider authorizes an outgoing upstream request with the workload's +//! ambient identity and never reads a static key from the binding or env: +//! - AWS: SigV4-sign using the SDK default chain (env / profile / SSO / IMDS / +//! IRSA), which on a workload is the instance role. The signing service name is +//! per-request: `bedrock` for the OpenAI-compatible endpoint, `bedrock-mantle` +//! for the Anthropic Messages endpoint. +//! - GCP / Azure: attach a bearer token fetched from the instance metadata service +//! (GCE metadata server / Azure IMDS), cached until shortly before it expires. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use alien_bindings::provider::LazyEnvBindingsProvider; +use alien_core::{AwsCredentials, AzureCredentials, ClientConfig, GcpCredentials}; +use alien_error::{AlienError, Context, IntoAlienError}; +use aws_credential_types::provider::error::CredentialsError; +use aws_credential_types::provider::{future, ProvideCredentials, SharedCredentialsProvider}; +use aws_credential_types::Credentials; +use aws_sigv4::http_request::{sign, SignableBody, SignableRequest, SigningSettings}; +use aws_sigv4::sign::v4; +use http::{HeaderName, HeaderValue}; +use tokio::sync::Mutex; + +use crate::error::{ErrorData, Result}; + +/// The shared workload credential resolver, resolved fresh on each authorization so a +/// long-lived gateway re-mints short-lived credentials before they expire. Held behind an +/// `Arc` because every route the mint-gated workload serves shares one provider (one mint +/// cache, one refresh clock). +type Managed = Arc; + +/// AWS credentials provider backed by the runtime-less resolver. The SigV4 signer calls +/// `provide_credentials` on every request, and `LazyEnvBindingsProvider::provider` re-mints +/// only when the cached credential is near expiry, so this refreshes transparently. +/// +/// The resolver is native-first: it yields minted keys only when the workload has no +/// projected identity. So both shapes reach here, and an `Imds` / `Profile` / `WebIdentity` +/// config is resolved through the SDK default chain — the same fallback +/// [`AwsSigV4Cred::from_client_config`] uses. Rejecting it would fail every request on a +/// mint-gated workload that has a projected identity. +#[derive(Debug)] +struct ManagedAwsCredentials { + provider: Managed, + /// The SDK default chain, built once on the first projected-identity request. + native: tokio::sync::OnceCell, +} + +impl ManagedAwsCredentials { + fn new(provider: Managed) -> Self { + Self { provider, native: tokio::sync::OnceCell::new() } + } + + /// The SDK default chain, which resolves and refreshes the workload's projected identity. + async fn native_chain(&self) -> std::result::Result<&SharedCredentialsProvider, CredentialsError> { + self.native + .get_or_try_init(|| async { + let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + config.credentials_provider().ok_or_else(|| { + CredentialsError::not_loaded("the AWS default credential chain provided no credentials provider") + }) + }) + .await + } + + async fn resolve(&self) -> std::result::Result { + let provider = self.provider.provider().await.map_err(CredentialsError::provider_error)?; + let aws = provider + .client_config() + .aws_config() + .ok_or_else(|| CredentialsError::not_loaded("the workload identity is not an AWS credential"))?; + match &aws.credentials { + AwsCredentials::AccessKeys { access_key_id, secret_access_key, session_token } => Ok( + Credentials::new(access_key_id, secret_access_key, session_token.clone(), None, "alien-managed"), + ), + AwsCredentials::SessionCredentials { access_key_id, secret_access_key, session_token, .. } => Ok( + Credentials::new(access_key_id, secret_access_key, Some(session_token.clone()), None, "alien-managed"), + ), + // The resolver selected the workload's projected identity (Imds / Profile / + // WebIdentity); the SDK default chain resolves and refreshes it. + _ => self.native_chain().await?.provide_credentials().await, + } + } +} + +impl ProvideCredentials for ManagedAwsCredentials { + fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a> + where + Self: 'a, + { + future::ProvideCredentials::new(self.resolve()) + } +} + +/// Injects the workload's ambient identity into an outgoing upstream request. +pub enum AmbientCred { + Aws(AwsSigV4Cred), + Bearer(BearerTokenCred), +} + +impl AmbientCred { + /// Authorize an outgoing upstream request. `aws_sigv4_service` is the SigV4 + /// service name (`bedrock` or `bedrock-mantle`); it is consumed only by the AWS + /// variant and ignored by bearer-token clouds. + pub async fn authorize(&self, req: &mut reqwest::Request, aws_sigv4_service: &str) -> Result<()> { + match self { + AmbientCred::Aws(c) => c.sign(req, aws_sigv4_service).await, + AmbientCred::Bearer(c) => c.attach(req).await, + } + } +} + +/// SigV4 signer for AWS Bedrock using the SDK default credential chain. +pub struct AwsSigV4Cred { + region: String, + provider: SharedCredentialsProvider, +} + +impl AwsSigV4Cred { + /// Resolve credentials from the SDK default chain (workload instance role in + /// production; env / profile / SSO locally). + pub async fn new(region: impl Into) -> Result { + let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + let provider = config.credentials_provider().ok_or_else(|| { + AlienError::new(ErrorData::WorkloadIdentityInvalid { + message: "the AWS default credential chain provided no credentials provider".to_string(), + }) + })?; + Ok(Self { region: region.into(), provider }) + } + + /// Build from an explicit credentials provider (used by tests). + pub fn with_provider(region: impl Into, provider: SharedCredentialsProvider) -> Self { + Self { region: region.into(), provider } + } + + /// Sign with credentials the runtime-less resolver mints and refreshes. Each request + /// re-resolves through the shared provider, so minted credentials never go stale. + pub fn managed(region: impl Into, provider: Managed) -> Self { + Self { + region: region.into(), + provider: SharedCredentialsProvider::new(ManagedAwsCredentials::new(provider)), + } + } + + /// Build from the workload's resolved `ClientConfig` — the identity the runtime-less + /// credential resolver selected: the native/projected identity, or Alien-minted + /// short-lived credentials. Explicit keys/session credentials are signed directly; + /// an instance-role/profile config falls back to the SDK default chain, which + /// resolves (and refreshes) them. + pub async fn from_client_config(region: impl Into, config: &ClientConfig) -> Result { + let region = region.into(); + let aws = config.aws_config().ok_or_else(|| { + AlienError::new(ErrorData::WorkloadIdentityInvalid { + message: "the workload ClientConfig is not an AWS config".to_string(), + }) + })?; + match &aws.credentials { + AwsCredentials::AccessKeys { + access_key_id, + secret_access_key, + session_token, + } => { + let creds = Credentials::new( + access_key_id, + secret_access_key, + session_token.clone(), + None, + "alien-workload", + ); + Ok(Self::with_provider(region, SharedCredentialsProvider::new(creds))) + } + AwsCredentials::SessionCredentials { + access_key_id, + secret_access_key, + session_token, + .. + } => { + let creds = Credentials::new( + access_key_id, + secret_access_key, + Some(session_token.clone()), + None, + "alien-workload", + ); + Ok(Self::with_provider(region, SharedCredentialsProvider::new(creds))) + } + // Imds / Profile: the SDK default chain resolves the projected identity. + _ => Self::new(region).await, + } + } + + async fn sign(&self, req: &mut reqwest::Request, service: &str) -> Result<()> { + let creds = self + .provider + .provide_credentials() + .await + .into_alien_error() + .context(ErrorData::AmbientCredentialUnavailable { + message: format!("the SigV4 signer for {service} got no credentials from the provider"), + })?; + let identity = creds.into(); + let params = v4::SigningParams::builder() + .identity(&identity) + .region(&self.region) + .name(service) + .time(std::time::SystemTime::now()) + .settings(SigningSettings::default()) + .build() + .into_alien_error() + .context(ErrorData::Other { message: "could not build SigV4 params".to_string() })?; + + // SigV4 hashes the exact body; a non-in-memory (streaming) body would sign as + // empty and silently fail upstream, so reject it rather than mis-sign. + let body_bytes: Vec = match req.body() { + Some(body) => body + .as_bytes() + .ok_or_else(|| { + AlienError::new(ErrorData::Other { + message: "cannot SigV4-sign a streaming (non-in-memory) request body".to_string(), + }) + })? + .to_vec(), + None => Vec::new(), + }; + let uri = req.url().to_string(); + let mut headers: Vec<(String, String)> = req + .headers() + .iter() + .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.as_str().to_string(), v.to_string()))) + .collect(); + // SigV4 must cover the Host header; reqwest sets it from the URL at send time, + // so sign the same value (with port when non-default) for the signature to match. + if let Some(host) = req.url().host_str() { + let host_header = match req.url().port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + headers.push(("host".to_string(), host_header)); + } + let signable = SignableRequest::new( + req.method().as_str(), + &uri, + headers.iter().map(|(k, v)| (k.as_str(), v.as_str())), + SignableBody::Bytes(&body_bytes), + ) + .into_alien_error() + .context(ErrorData::Other { message: "could not build signable request".to_string() })?; + + let (instructions, _signature) = sign(signable, ¶ms.into()) + .into_alien_error() + .context(ErrorData::Other { message: "SigV4 signing failed".to_string() })? + .into_parts(); + + for (name, value) in instructions.headers() { + let hn = HeaderName::from_bytes(name.as_bytes()) + .into_alien_error() + .context(ErrorData::Other { message: format!("invalid signed header name {name}") })?; + let hv = HeaderValue::from_str(value) + .into_alien_error() + .context(ErrorData::Other { message: "invalid signed header value".to_string() })?; + req.headers_mut().insert(hn, hv); + } + Ok(()) + } +} + +/// Where a bearer token comes from: the GCE metadata server, Azure IMDS, or a +/// pre-supplied token (local-dev ADC / `az` CLI token, or tests). +enum BearerSource { + Gcp, + Azure { resource: String }, + Static { token: String }, + /// The runtime-less resolver: re-resolve the workload's bearer token on each request, + /// re-minting when the short-lived credential is near expiry. The resolver is + /// native-first, so it may instead select the workload's projected identity, which + /// carries no bearer token — `native` is the metadata source that issues one. + Managed { provider: Managed, native: Box }, +} + +/// Attaches an ambient bearer token — fetched and cached from the cloud metadata endpoint, +/// or supplied directly (static / minted). +pub struct BearerTokenCred { + source: BearerSource, + client: reqwest::Client, + cache: Mutex>, +} + +impl BearerTokenCred { + pub fn gcp() -> Self { + Self { source: BearerSource::Gcp, client: reqwest::Client::new(), cache: Mutex::new(None) } + } + + /// `resource` is the audience, e.g. `https://cognitiveservices.azure.com`. + pub fn azure(resource: impl Into) -> Self { + Self { + source: BearerSource::Azure { resource: resource.into() }, + client: reqwest::Client::new(), + cache: Mutex::new(None), + } + } + + /// Use a pre-supplied bearer token instead of the metadata service — for + /// local development (an ADC / `az` CLI token) or tests. + pub fn static_token(token: impl Into) -> Self { + Self { + source: BearerSource::Static { token: token.into() }, + client: reqwest::Client::new(), + cache: Mutex::new(None), + } + } + + /// Resolve the GCP bearer token from the runtime-less resolver, falling back to the + /// metadata service when the resolver selects the workload's projected identity. + pub fn managed_gcp(provider: Managed) -> Self { + Self::managed(provider, BearerSource::Gcp) + } + + /// Resolve the Azure bearer token from the runtime-less resolver, falling back to IMDS + /// for `resource` when the resolver selects the workload's projected identity. + pub fn managed_azure(provider: Managed, resource: impl Into) -> Self { + Self::managed(provider, BearerSource::Azure { resource: resource.into() }) + } + + fn managed(provider: Managed, native: BearerSource) -> Self { + Self { + source: BearerSource::Managed { provider, native: Box::new(native) }, + client: reqwest::Client::new(), + cache: Mutex::new(None), + } + } + + async fn attach(&self, req: &mut reqwest::Request) -> Result<()> { + let token = self.token().await?; + let hv = HeaderValue::from_str(&format!("Bearer {token}")) + .into_alien_error() + .context(ErrorData::Other { message: "invalid bearer token".to_string() })?; + req.headers_mut().insert(http::header::AUTHORIZATION, hv); + Ok(()) + } + + async fn token(&self) -> Result { + match &self.source { + // A supplied token has no refresh clock of its own, and the resolver holds one + // for minted credentials, so neither uses the local metadata cache. + BearerSource::Static { token } => Ok(token.clone()), + BearerSource::Managed { provider, native } => match self.managed_token(provider, native).await? { + Some(token) => Ok(token), + None => self.metadata_token(native).await, + }, + native => self.metadata_token(native).await, + } + } + + /// The bearer token the resolver selected, or `None` when it selected the workload's + /// projected identity — which carries no token of its own, so the metadata service + /// issues one. + async fn managed_token(&self, provider: &Managed, native: &BearerSource) -> Result> { + let resolved = provider.provider().await.context(ErrorData::AmbientCredentialUnavailable { + message: "the workload credential resolver failed".to_string(), + })?; + Ok(match native { + BearerSource::Gcp => resolved.client_config().gcp_config().and_then(|g| match &g.credentials { + GcpCredentials::AccessToken { token } => Some(token.clone()), + _ => None, + }), + BearerSource::Azure { .. } => resolved.client_config().azure_config().and_then(|a| match &a.credentials { + AzureCredentials::AccessToken { token } => Some(token.clone()), + _ => None, + }), + // `managed` only ever builds a Gcp / Azure native source. + BearerSource::Static { .. } | BearerSource::Managed { .. } => None, + }) + } + + /// Cache-then-fetch the workload's projected-identity token from the instance metadata + /// service. `source` must be `Gcp` or `Azure`. + async fn metadata_token(&self, source: &BearerSource) -> Result { + if let Some((tok, exp)) = self.cache.lock().await.as_ref() { + if Instant::now() < *exp { + return Ok(tok.clone()); + } + } + + let (url, header_name, header_value) = match source { + BearerSource::Gcp => ( + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token".to_string(), + "Metadata-Flavor", + "Google".to_string(), + ), + BearerSource::Azure { resource } => ( + format!("http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource={resource}"), + "Metadata", + "true".to_string(), + ), + BearerSource::Static { .. } | BearerSource::Managed { .. } => { + return Err(AlienError::new(ErrorData::Other { + message: "no metadata endpoint for this credential source".to_string(), + })) + } + }; + + let resp = self + .client + .get(&url) + .header(header_name, header_value) + .send() + .await + .into_alien_error() + .context(ErrorData::AmbientCredentialUnavailable { + message: "could not reach the instance metadata service".to_string(), + })?; + if !resp.status().is_success() { + return Err(AlienError::new(ErrorData::AmbientCredentialUnavailable { + message: format!("metadata token endpoint returned {}", resp.status()), + })); + } + // GCP returns expires_in as a number; Azure IMDS returns it as a string. + let v: serde_json::Value = resp + .json() + .await + .into_alien_error() + .context(ErrorData::Other { message: "could not parse the metadata token response".to_string() })?; + let access_token = v["access_token"] + .as_str() + .ok_or_else(|| AlienError::new(ErrorData::Other { + message: "metadata token response had no access_token".to_string(), + }))? + .to_string(); + // A wrong shape here would silently cache a token past its real expiry, and there is + // no 401-triggered invalidation to recover — so fail rather than invent a lifetime. + let expires_in = v["expires_in"] + .as_u64() + .or_else(|| v["expires_in"].as_str().and_then(|s| s.parse().ok())) + .ok_or_else(|| { + AlienError::new(ErrorData::AmbientCredentialUnavailable { + message: "metadata token response had no usable expires_in".to_string(), + }) + })?; + + let exp = Instant::now() + Duration::from_secs(expires_in.saturating_sub(60)); + *self.cache.lock().await = Some((access_token.clone(), exp)); + Ok(access_token) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn aws_signer_adds_sigv4_headers() { + // Hermetic: sign with explicit static creds (no env / network needed). + let creds = aws_credential_types::Credentials::new( + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + None, + None, + "test", + ); + let cred = AwsSigV4Cred::with_provider("us-east-2", SharedCredentialsProvider::new(creds)); + + let mut req = reqwest::Client::new() + .post("https://bedrock-runtime.us-east-2.amazonaws.com/openai/v1/chat/completions") + .body(r#"{"model":"openai.gpt-oss-20b-1:0","messages":[]}"#) + .build() + .expect("request builds"); + + cred.sign(&mut req, "bedrock").await.expect("signing succeeds"); + + let auth = req + .headers() + .get(http::header::AUTHORIZATION) + .expect("authorization header present") + .to_str() + .unwrap(); + assert!(auth.contains("AWS4-HMAC-SHA256"), "must be a SigV4 auth header: {auth}"); + assert!(auth.contains("/bedrock/"), "credential scope must name the bedrock service: {auth}"); + assert!(req.headers().contains_key("x-amz-date"), "must add x-amz-date"); + } + + #[tokio::test] + async fn aws_signer_signs_classic_invoke_stream_url() { + // Claude routes through classic InvokeModel; the signer must handle the + // profile id in the URL path and sign it under the same `bedrock` service. + let creds = aws_credential_types::Credentials::new( + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + None, + None, + "test", + ); + let cred = AwsSigV4Cred::with_provider("us-east-2", SharedCredentialsProvider::new(creds)); + + let mut req = reqwest::Client::new() + .post("https://bedrock-runtime.us-east-2.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream") + .body(r#"{"anthropic_version":"bedrock-2023-05-31","messages":[]}"#) + .build() + .expect("request builds"); + + cred.sign(&mut req, "bedrock").await.expect("signing succeeds"); + + let auth = req + .headers() + .get(http::header::AUTHORIZATION) + .expect("authorization header present") + .to_str() + .unwrap(); + assert!( + auth.contains("/bedrock/"), + "credential scope must name the bedrock service: {auth}" + ); + } +} diff --git a/crates/alien-gateway/src/error.rs b/crates/alien-gateway/src/error.rs new file mode 100644 index 000000000..12f1536d4 --- /dev/null +++ b/crates/alien-gateway/src/error.rs @@ -0,0 +1,102 @@ +use alien_error::AlienErrorData; +use serde::{Deserialize, Serialize}; + +/// Errors raised while starting or serving the embedded AI gateway. +#[derive(Debug, Clone, AlienErrorData, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ErrorData { + /// The gateway could not bind its loopback listener. + #[error( + code = "GATEWAY_BIND_FAILED", + message = "Failed to bind the AI gateway on {address}", + retryable = "false", + internal = "true" + )] + BindFailed { address: String }, + + /// The request named a binding the gateway does not serve. + #[error( + code = "GATEWAY_UNKNOWN_BINDING", + message = "No AI binding named '{binding}'", + retryable = "false", + internal = "false", + http_status_code = 404 + )] + UnknownBinding { binding: String }, + + /// The request body was not a usable model request. + #[error( + code = "GATEWAY_INVALID_REQUEST", + message = "Invalid AI request: {message}", + retryable = "false", + internal = "false", + http_status_code = 400 + )] + InvalidRequest { message: String }, + + /// The requested model is not in the catalog for this binding's cloud. + #[error( + code = "GATEWAY_MODEL_NOT_AVAILABLE", + message = "Model '{model}' is not available on binding '{binding}'", + retryable = "false", + internal = "false", + http_status_code = 404 + )] + ModelNotAvailable { model: String, binding: String }, + + /// The upstream cloud endpoint could not be reached. Not internal: the 502 and + /// its message (a cloud endpoint host, never a credential) are safe to surface, + /// and `internal = true` would collapse this to a generic 500 at the client. + #[error( + code = "GATEWAY_UPSTREAM_FAILED", + message = "Upstream AI endpoint request failed: {message}", + retryable = "true", + internal = "false", + http_status_code = 502 + )] + UpstreamFailed { message: String }, + + /// The workload's ambient cloud identity could not be obtained (no instance + /// role, or the metadata service is unreachable) — transient, so retryable. + #[error( + code = "GATEWAY_AMBIENT_CREDENTIAL_UNAVAILABLE", + message = "Could not obtain the workload's ambient cloud credential: {message}", + retryable = "true", + internal = "true" + )] + AmbientCredentialUnavailable { message: String }, + + /// The workload's resolved identity cannot authorize this binding's cloud (e.g. an + /// AWS binding whose workload identity is not an AWS credential). A permanent + /// invariant failure, so retrying cannot succeed. + #[error( + code = "GATEWAY_WORKLOAD_IDENTITY_INVALID", + message = "The workload identity cannot authorize this binding: {message}", + retryable = "false", + internal = "true" + )] + WorkloadIdentityInvalid { message: String }, + + /// A binding's projected configuration is unusable (missing a required field, or + /// a cloud the gateway does not serve). User-fixable, so not internal. + #[error( + code = "GATEWAY_BINDING_CONFIG_INVALID", + message = "AI binding '{binding}' is invalid: {message}", + retryable = "false", + internal = "false", + http_status_code = 400 + )] + BindingConfigInvalid { binding: String, message: String }, + + /// Generic catch-all for unexpected, non-retryable gateway failures (build, + /// serialization, or configuration bugs). Transient cases get their own variant. + #[error( + code = "GATEWAY_ERROR", + message = "AI gateway error: {message}", + retryable = "false", + internal = "true" + )] + Other { message: String }, +} + +pub type Result = alien_error::Result; diff --git a/crates/alien-gateway/src/lib.rs b/crates/alien-gateway/src/lib.rs new file mode 100644 index 000000000..67838a190 --- /dev/null +++ b/crates/alien-gateway/src/lib.rs @@ -0,0 +1,178 @@ +//! Embedded, protocol-agnostic AI gateway: a loopback HTTP server that injects the +//! workload's ambient cloud credential and proxies each request to the model's +//! native upstream endpoint without translating the body. +//! +//! The runtime starts the gateway before the app process (`start_gateway`) and +//! passes its URL to the app; routing lives in `router`, credential injection in +//! `creds`. + +mod config; +mod creds; +mod error; +mod router; +pub use config::{bindings_from_env, bindings_from_env_map}; +pub use creds::{AmbientCred, AwsSigV4Cred, BearerTokenCred}; +pub use error::{ErrorData, Result}; +pub use router::{build_router, GatewayRoute}; + +use std::net::{Ipv4Addr, SocketAddr}; + +use alien_core::Platform; +use alien_error::{Context, IntoAlienError}; +use axum::routing::get; + +/// The readiness endpoint the gateway serves and every caller polls. Shared so the +/// path and the poll budget below cannot drift between the runtime and the launcher. +pub const READY_PATH: &str = "/healthz/ready"; +/// Readiness poll budget: `READY_POLL_TRIES` attempts, `READY_POLL_INTERVAL_MS` apart. +/// ~1s covers process spawn + tokio init + local config assembly; ambient credential +/// resolution is lazy (deferred to the first request), so no network I/O gates the bind. +pub const READY_POLL_TRIES: usize = 20; +pub const READY_POLL_INTERVAL_MS: u64 = 50; + +/// One `ai` resource the gateway serves. An Alien `ai` binding maps to exactly one +/// cloud, so each binding fixes the upstream host and ambient credential; the +/// request's model selects the model and (via the catalog) the protocol path. +#[derive(Debug, Clone)] +pub struct GatewayBinding { + /// The binding name (the path segment the app calls: `//...`). + pub name: String, + pub cloud: Platform, + /// AWS region or GCP location. + pub region: Option, + /// GCP project id. + pub project: Option, + /// Azure account endpoint, e.g. `https://acct.openai.azure.com/`. + pub azure_endpoint: Option, +} + +/// A running gateway: its loopback base URL and the server task that keeps it alive +/// for the process lifetime. Dropping the handle aborts the server. +pub struct GatewayHandle { + pub url: String, + server: tokio::task::JoinHandle<()>, +} + +impl Drop for GatewayHandle { + /// Dropping a `JoinHandle` only detaches its task, so abort explicitly — otherwise the + /// server outlives the handle and keeps the port bound until the process exits. + fn drop(&mut self) { + self.server.abort(); + } +} + +/// Start the gateway on an ephemeral loopback port and return its base URL once the +/// listener is bound. Each binding's ambient credential is resolved up front and +/// mounted as a proxy route under `/`; `/healthz/ready` answers immediately. +pub async fn start_gateway(bindings: Vec) -> Result { + start_gateway_on(bindings, SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await +} + +/// Like [`start_gateway`] but binds an explicit address. The container launcher uses a +/// fixed loopback port here because it tells the app the URL out of band (an env var) +/// rather than reading back a randomly assigned port. +pub async fn start_gateway_on( + bindings: Vec, + addr: SocketAddr, +) -> Result { + // Built once and shared across routes: on the runtime-less mint path it is the single + // refresh clock and mint cache every request re-resolves through. + let managed = config::managed_provider()?; + let mut routes = Vec::with_capacity(bindings.len()); + for binding in bindings { + routes.push(config::resolve_route(binding, managed.as_ref()).await?); + } + let router = build_router(routes).route(READY_PATH, get(ready)); + + let listener = tokio::net::TcpListener::bind(addr) + .await + .into_alien_error() + .context(ErrorData::BindFailed { + address: addr.to_string(), + })?; + let bound = listener + .local_addr() + .into_alien_error() + .context(ErrorData::Other { + message: "could not read the gateway's bound address".to_string(), + })?; + let url = format!("http://{bound}"); + + let server = tokio::spawn(async move { + if let Err(e) = axum::serve(listener, router).await { + tracing::error!(error = %e, "AI gateway server stopped"); + } + }); + + Ok(GatewayHandle { url, server }) +} + +async fn ready() -> &'static str { + "ready" +} + +/// Blocks until the gateway at `base_url` answers `READY_PATH`, within the shared poll +/// budget. Returns `false` if it never became ready. The launcher (a non-async process +/// entrypoint) uses this; the runtime uses an async twin sharing the same constants. +pub fn wait_until_ready_blocking(base_url: &str) -> bool { + let url = format!("{base_url}{READY_PATH}"); + let client = reqwest::blocking::Client::new(); + for _ in 0..READY_POLL_TRIES { + if let Ok(resp) = client.get(&url).send() { + if resp.status().is_success() { + return true; + } + } + std::thread::sleep(std::time::Duration::from_millis(READY_POLL_INTERVAL_MS)); + } + false +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[tokio::test] + async fn gateway_starts_and_serves_health() { + let handle = start_gateway(vec![]).await.expect("gateway should start"); + let body = reqwest::get(format!("{}/healthz/ready", handle.url)) + .await + .expect("health request should succeed") + .text() + .await + .expect("health body should read"); + assert_eq!(body.trim(), "ready"); + } + + #[tokio::test] + async fn gateway_binds_the_requested_port() { + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 0)); + let handle = start_gateway_on(vec![], addr) + .await + .expect("gateway should start on the given addr"); + // URL reflects a concrete loopback bind, not the placeholder :0 + assert!(handle.url.starts_with("http://127.0.0.1:")); + assert!(!handle.url.ends_with(":0")); + } + + /// Dropping the handle must stop the server: a detached task would keep serving (and + /// keep the port bound) for the rest of the process. + #[tokio::test] + async fn dropping_the_handle_stops_the_server() { + let handle = start_gateway(vec![]).await.expect("gateway should start"); + let url = format!("{}/healthz/ready", handle.url); + assert!(reqwest::get(&url).await.is_ok(), "the gateway serves while the handle is held"); + + drop(handle); + // The abort lands on the next scheduler pass. + tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(50)).await; + + assert!( + reqwest::get(&url).await.is_err(), + "the gateway must stop serving once its handle is dropped" + ); + } +} diff --git a/crates/alien-gateway/src/router.rs b/crates/alien-gateway/src/router.rs new file mode 100644 index 000000000..7f45c6100 --- /dev/null +++ b/crates/alien-gateway/src/router.rs @@ -0,0 +1,2017 @@ +//! The pure proxy: route a request to the model's native cloud endpoint, inject the +//! workload's ambient credential, and stream the response back without translating +//! the body. The only edit to the request body is rewriting the public model id to +//! the catalog's upstream id; the response (JSON or SSE) is passed through byte-for-byte. + +use std::collections::HashMap; +use std::sync::Arc; + +use alien_core::ai_catalog::{self, Protocol}; +use alien_core::Platform; +use alien_error::{AlienError, Context, IntoAlienError}; +use aws_smithy_eventstream::frame::{DecodedFrame, MessageFrameDecoder}; +use aws_smithy_types::event_stream::Message; +use axum::{ + body::{Body, Bytes}, + extract::{Path, State}, + http::{header, HeaderMap, StatusCode}, + response::{IntoResponse, Json, Response}, + routing::{get, post}, + Router, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use futures::StreamExt; +use serde_json::{json, Map, Value}; +use tracing::warn; + +use crate::creds::AmbientCred; +use crate::error::{ErrorData, Result}; + +/// One binding resolved into everything the proxy needs to serve it: the cloud (for +/// catalog filtering and upstream selection), the location fields used to build the +/// upstream URL, and the ambient credential. +pub struct GatewayRoute { + /// The binding name — the first path segment the app calls (`//...`). + pub name: String, + pub cloud: Platform, + /// AWS region or GCP location. + pub region: Option, + /// GCP project id. + pub project: Option, + /// Azure account endpoint, e.g. `https://acct.openai.azure.com/`. + pub azure_endpoint: Option, + pub cred: AmbientCred, + /// When set, upstream requests target this base URL instead of the cloud-derived + /// host (the per-protocol path is still appended). Lets tests aim a binding at a + /// mock upstream. + pub upstream_base_override: Option, +} + +struct AppState { + routes: HashMap, + client: reqwest::Client, +} + +/// Build the axum router serving every binding under `//...`: +/// `POST //v1/chat/completions` (OpenAI), `POST //v1/messages` +/// (Anthropic), and `GET //v1/models`. +pub fn build_router(routes: Vec) -> Router { + let routes = routes.into_iter().map(|r| (r.name.clone(), r)).collect(); + let state = Arc::new(AppState { + routes, + client: reqwest::Client::new(), + }); + Router::new() + .route("/{binding}/v1/chat/completions", post(proxy)) + .route("/{binding}/v1/messages", post(proxy)) + .route("/{binding}/v1/responses", post(proxy_responses)) + .route("/{binding}/v1/models", get(list_models)) + .with_state(state) +} + +/// Parse a proxied request body as JSON and pull out its required `model` field. +/// Both the chat/completions|messages handler and the Responses handler route on +/// the request's `model`, so they share this preamble. +fn parse_model_request(body: &[u8]) -> Result<(Value, String)> { + let payload: Value = serde_json::from_slice(body) + .into_alien_error() + .context(ErrorData::InvalidRequest { + message: "request body is not valid JSON".to_string(), + })?; + let model = payload + .get("model") + .and_then(Value::as_str) + .ok_or_else(|| { + AlienError::new(ErrorData::InvalidRequest { + message: "request body has no \"model\" field".to_string(), + }) + })? + .to_string(); + Ok((payload, model)) +} + +/// Forward an upstream reply to the client untouched: its status, content-type, and +/// body, streamed straight through. Streaming the body works identically for a +/// single JSON object and for an SSE stream. +fn forward_response(upstream: reqwest::Response) -> Result { + let status = + StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let content_type = upstream.headers().get(header::CONTENT_TYPE).cloned(); + let mut response = Response::builder().status(status); + if let Some(ct) = content_type { + response = response.header(header::CONTENT_TYPE, ct); + } + response + .body(Body::from_stream(upstream.bytes_stream())) + .into_alien_error() + .context(ErrorData::Other { + message: "could not build the proxied response".to_string(), + }) +} + +/// Build a JSON POST to `url`, sign it with the ambient credential for `service`, +/// and execute it. The handlers differ only in URL, signing service, body, and any +/// protocol-required header, so the build + sign + execute + upstream-error +/// scaffolding lives here once. +async fn sign_and_execute( + client: &reqwest::Client, + cred: &AmbientCred, + url: &str, + service: &str, + body: Vec, + extra_headers: &[(&str, &str)], +) -> Result { + let mut builder = client + .post(url) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in extra_headers { + builder = builder.header(*name, *value); + } + let mut req = builder + .body(body) + .build() + .into_alien_error() + .context(ErrorData::Other { + // The url names which upstream failed; the handlers otherwise share + // this message and a bare one cannot be traced back to a path. + message: format!("could not build the upstream request to {url}"), + })?; + cred.authorize(&mut req, service).await?; + client + .execute(req) + .await + .into_alien_error() + .context(ErrorData::UpstreamFailed { + message: format!("request to {url} failed"), + }) +} + +/// Proxy a chat/completions or messages request. Routes purely by the request's +/// `model` (the catalog is the single source of truth for protocol + cloud), so the +/// same handler serves both the OpenAI and Anthropic entry paths. +async fn proxy( + State(state): State>, + Path(binding): Path, + headers: HeaderMap, + body: Bytes, +) -> Result { + let route = state.routes.get(&binding).ok_or_else(|| { + AlienError::new(ErrorData::UnknownBinding { + binding: binding.clone(), + }) + })?; + + let (mut payload, model) = parse_model_request(&body)?; + + // Cloud-scoped resolution: Claude ids appear once per cloud, so a first-match + // resolve would always land on another cloud's entry and fail the cloud filter. + let cm = ai_catalog::resolve_for(&model, route.cloud).ok_or_else(|| { + AlienError::new(ErrorData::ModelNotAvailable { + model: model.clone(), + binding: binding.clone(), + }) + })?; + + // AWS serves Claude through classic Bedrock InvokeModel, not the passthrough + // endpoint: the model id travels in the URL and the streamed reply is AWS + // event-stream framing, so it needs its own request/response shape. + if route.cloud == Platform::Aws && cm.protocol == Protocol::Anthropic { + return proxy_bedrock_anthropic(&state.client, route, cm.upstream_id, payload, &headers) + .await; + } + // GCP serves Claude through Vertex rawPredict: the model id travels in the URL + // and streaming is chosen by the URL verb, but the reply is native Anthropic + // JSON/SSE — no decoder needed, unlike Bedrock. + if route.cloud == Platform::Gcp && cm.protocol == Protocol::Anthropic { + return proxy_vertex_anthropic(&state.client, route, cm.upstream_id, payload, &headers) + .await; + } + // Azure serves Claude through Foundry's Anthropic endpoint: standard Messages + // in both directions, on the `/anthropic/v1` path with the version header. + if route.cloud == Platform::Azure && cm.protocol == Protocol::Anthropic { + return proxy_foundry_anthropic(&state.client, route, cm.upstream_id, payload, &headers) + .await; + } + + payload["model"] = Value::String(cm.upstream_id.to_string()); + let upstream_body = serde_json::to_vec(&payload) + .into_alien_error() + .context(ErrorData::Other { + message: "could not re-serialize the rewritten request body".to_string(), + })?; + + let (url, aws_service) = upstream_target(route, cm.protocol)?; + + let upstream = + sign_and_execute(&state.client, &route.cred, &url, aws_service, upstream_body, &[]).await?; + + forward_response(upstream) +} + +/// Proxy an OpenAI Responses request (`POST //v1/responses`, used by Codex). +/// AWS serves the Responses API natively on the bedrock-mantle endpoint, so this is +/// the same pure passthrough as `proxy` — rewrite the model id, sign, stream back — +/// but aimed at the mantle endpoint. Only AWS OpenAI-protocol models are servable here: the +/// other clouds don't expose a Responses endpoint, and Claude on mantle is +/// Messages-only. +async fn proxy_responses( + State(state): State>, + Path(binding): Path, + body: Bytes, +) -> Result { + let route = state.routes.get(&binding).ok_or_else(|| { + AlienError::new(ErrorData::UnknownBinding { + binding: binding.clone(), + }) + })?; + + let (mut payload, model) = parse_model_request(&body)?; + + // The Responses table implies AWS; the binding's cloud must still match so a + // GCP/Azure binding doesn't forward to an AWS endpoint it has no credential for. + let upstream_id = ai_catalog::responses_upstream_id(&model) + .filter(|_| route.cloud == Platform::Aws) + .ok_or_else(|| { + AlienError::new(ErrorData::ModelNotAvailable { + model: model.clone(), + binding: binding.clone(), + }) + })?; + + payload["model"] = Value::String(upstream_id.to_string()); + let upstream_body = serde_json::to_vec(&payload) + .into_alien_error() + .context(ErrorData::Other { + message: "could not re-serialize the rewritten request body".to_string(), + })?; + + let region = route.region.as_deref().ok_or_else(|| missing_field(route, "region"))?; + let base = route + .upstream_base_override + .clone() + .unwrap_or_else(|| format!("https://bedrock-mantle.{region}.api.aws")); + let url = format!("{}/v1/responses", base.trim_end_matches('/')); + + let upstream = + sign_and_execute(&state.client, &route.cred, &url, "bedrock-mantle", upstream_body, &[]) + .await?; + + forward_response(upstream) +} + +/// `GET //v1/models` — the binding's cloud's curated catalog, in OpenAI list shape. +async fn list_models( + State(state): State>, + Path(binding): Path, +) -> Result { + let route = state.routes.get(&binding).ok_or_else(|| { + AlienError::new(ErrorData::UnknownBinding { binding }) + })?; + let data: Vec = ai_catalog::models_for(route.cloud) + .iter() + .map(|m| json!({ "id": m.public_id, "object": "model" })) + .collect(); + Ok(Json(json!({ "object": "list", "data": data })).into_response()) +} + +/// The error for a binding missing a field a handler needs. +fn missing_field(route: &GatewayRoute, field: &str) -> AlienError { + AlienError::new(ErrorData::BindingConfigInvalid { + binding: route.name.clone(), + message: format!("it is missing its {field}"), + }) +} + +/// The upstream URL and (for AWS) the SigV4 service name for a binding + protocol. +fn upstream_target(route: &GatewayRoute, protocol: Protocol) -> Result<(String, &'static str)> { + let (host, path, aws_service) = match (route.cloud, protocol) { + (Platform::Aws, Protocol::OpenAi) => { + let region = route.region.as_deref().ok_or_else(|| missing_field(route, "region"))?; + ( + format!("https://bedrock-runtime.{region}.amazonaws.com"), + "/openai/v1/chat/completions".to_string(), + "bedrock", + ) + } + (Platform::Gcp, Protocol::OpenAi) => { + let location = + route.region.as_deref().ok_or_else(|| missing_field(route, "location"))?; + let project = + route.project.as_deref().ok_or_else(|| missing_field(route, "project"))?; + ( + vertex_host(location), + format!( + "/v1/projects/{project}/locations/{location}/endpoints/openapi/chat/completions" + ), + "", + ) + } + (Platform::Azure, Protocol::OpenAi) => { + let endpoint = + route.azure_endpoint.as_deref().ok_or_else(|| missing_field(route, "endpoint"))?; + ( + endpoint.trim_end_matches('/').to_string(), + "/openai/v1/chat/completions".to_string(), + "", + ) + } + (cloud, proto) => { + return Err(AlienError::new(ErrorData::Other { + message: format!("{cloud:?} does not serve the {proto:?} protocol"), + })) + } + }; + + let base = route + .upstream_base_override + .clone() + .unwrap_or(host); + Ok((format!("{}{}", base.trim_end_matches('/'), path), aws_service)) +} + +/// Read a request's `stream` field. Streaming picks between two different +/// upstream shapes, so a malformed value must be a loud 400 — coercing it would +/// answer an SSE client with a JSON body it can only interpret as a hang. +fn parse_stream_flag(value: Option) -> Result { + match value { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(value)) => Ok(value), + Some(_) => Err(AlienError::new(ErrorData::InvalidRequest { + message: "the `stream` field must be a boolean".to_string(), + })), + } +} + +/// The Vertex AI Platform host for a location: the global endpoint is the +/// un-prefixed host; a region prefixes it. The path carries `locations/{location}` +/// either way. +fn vertex_host(location: &str) -> String { + if location == "global" { + "https://aiplatform.googleapis.com".to_string() + } else { + format!("https://{location}-aiplatform.googleapis.com") + } +} + +/// Serve a Claude request through Vertex `rawPredict`. Nearly the Anthropic +/// Messages API: the model id travels in the URL, streaming picks the URL verb +/// (`:streamRawPredict`), and the body carries Vertex's version marker instead of +/// a `model`. The reply is native Anthropic JSON/SSE, so unlike the Bedrock shim +/// there is no event-stream decoder — and betas ride the standard `anthropic-beta` +/// header rather than a body field, since Vertex speaks the native Messages API. +async fn proxy_vertex_anthropic( + client: &reqwest::Client, + route: &GatewayRoute, + upstream_id: &str, + mut payload: Value, + headers: &HeaderMap, +) -> Result { + let location = route.region.as_deref().ok_or_else(|| missing_field(route, "location"))?; + let project = route.project.as_deref().ok_or_else(|| missing_field(route, "project"))?; + + let obj = payload.as_object_mut().ok_or_else(|| { + AlienError::new(ErrorData::InvalidRequest { + message: "request body must be a JSON object".to_string(), + }) + })?; + obj.remove("model"); + obj.insert("anthropic_version".to_string(), json!("vertex-2023-10-16")); + // The `stream` field stays in the body; Vertex accepts it alongside the verb. + let stream = parse_stream_flag(obj.get("stream").cloned())?; + let verb = if stream { "streamRawPredict" } else { "rawPredict" }; + + let base = route.upstream_base_override.clone().unwrap_or_else(|| vertex_host(location)); + let url = format!( + "{}/v1/projects/{project}/locations/{location}/publishers/anthropic/models/{upstream_id}:{verb}", + base.trim_end_matches('/') + ); + + let upstream_body = serde_json::to_vec(&payload) + .into_alien_error() + .context(ErrorData::Other { + message: "could not re-serialize the rewritten request body".to_string(), + })?; + + // Vertex is the native Messages API, so betas ride the standard header — + // filtered through the same allowlist that keeps Anthropic-API-side markers + // (notably oauth-2025-04-20) from turning the request into a 400. + let betas = filtered_header_betas(headers).join(","); + let mut extra_headers: Vec<(&str, &str)> = Vec::new(); + if !betas.is_empty() { + extra_headers.push(("anthropic-beta", betas.as_str())); + } + let upstream = + sign_and_execute(client, &route.cred, &url, "", upstream_body, &extra_headers).await?; + forward_response(upstream) +} + +/// The Messages API version the gateway bridges to Foundry's Anthropic endpoint; +/// Foundry reads it from the standard `anthropic-version` header. +const FOUNDRY_ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// Serve a Claude request through Foundry's Anthropic endpoint. The closest arm to +/// the plain passthrough: the model stays in the body (rewritten to the Foundry +/// deployment name), streaming is the standard body field, and the reply is native +/// Anthropic JSON/SSE — only the version header and the `/anthropic/v1` path +/// distinguish it from the OpenAI arm. +async fn proxy_foundry_anthropic( + client: &reqwest::Client, + route: &GatewayRoute, + upstream_id: &str, + mut payload: Value, + headers: &HeaderMap, +) -> Result { + let endpoint = + route.azure_endpoint.as_deref().ok_or_else(|| missing_field(route, "endpoint"))?; + + payload["model"] = Value::String(upstream_id.to_string()); + let upstream_body = serde_json::to_vec(&payload) + .into_alien_error() + .context(ErrorData::Other { + message: "could not re-serialize the rewritten request body".to_string(), + })?; + + // The binding carries the AIServices account endpoint; the Anthropic path + // serves on that account. Whether the account host also needs the Entra + // audience swapped to https://ai.azure.com is settled by the live Foundry + // probe — the credential keeps the account audience until that probe says + // otherwise. + let base = route + .upstream_base_override + .clone() + .unwrap_or_else(|| endpoint.to_string()); + let url = format!("{}/anthropic/v1/messages", base.trim_end_matches('/')); + + // Foundry speaks the standard Anthropic protocol, so betas ride the standard + // header — filtered through the same allowlist that keeps Anthropic-API-side + // markers from turning the request into a 400. + let betas = filtered_header_betas(headers).join(","); + let mut extra_headers = vec![("anthropic-version", FOUNDRY_ANTHROPIC_VERSION)]; + if !betas.is_empty() { + extra_headers.push(("anthropic-beta", betas.as_str())); + } + let upstream = + sign_and_execute(client, &route.cred, &url, "", upstream_body, &extra_headers).await?; + forward_response(upstream) +} + +/// The client-executed tool families Bedrock hosts on classic `InvokeModel` +/// (verified against AWS docs). Anything else typed is server-executed by Anthropic's +/// own API servers, which Bedrock is not, so it is dropped rather than 400'd. +const BEDROCK_HOSTED_TOOL_PREFIXES: &[&str] = + &["bash_", "text_editor_", "computer_", "memory_", "tool_search_"]; + +/// The `anthropic_beta` families Bedrock's classic `InvokeModel` accepts in the body +/// (each live-verified: an accepted tag returns 200, an unknown one is a +/// ValidationException "invalid beta flag"). Anthropic-API-side markers — notably +/// `oauth-2025-04-20`, which every OAuth-authenticated Claude Code request declares — +/// are rejected, so the header bridge folds only these families across. +const BEDROCK_BETA_PREFIXES: &[&str] = &[ + "claude-code-", + "computer-use-", + "context-1m-", + "context-management-", + "fine-grained-tool-streaming-", + "interleaved-thinking-", + "output-128k-", + "token-efficient-tools-", + "tool-examples-", +]; + +/// Serve a Claude request through classic Bedrock `InvokeModel`. The Anthropic +/// Messages body *is* the InvokeModel body, but the model id travels in the URL +/// (as a cross-region inference profile) and streaming is chosen by the URL +/// suffix — so, unlike the passthrough path, the body carries neither, and the +/// streamed reply arrives as AWS event-stream framing we decode back into the +/// Anthropic SSE the client expects. +/// +/// This whole function is a protocol shim, kept only until Bedrock's mantle +/// endpoint serves Claude on the same standard model access InvokeModel already +/// grants. Where mantle does not yet serve Claude for a given account/region it +/// returns 403 while the same model and credential serve 200 via InvokeModel; +/// this shim bridges that gap. Drop it (and the decoder below) in favor of the +/// plain mantle passthrough once mantle serves Claude directly — to check whether +/// a region already does: +/// +/// ```text +/// curl --aws-sigv4 "aws:amz::bedrock-mantle" --user "$KEY:$SECRET" \ +/// -H "x-amz-security-token: $TOKEN" -H "content-type: application/json" \ +/// -d '{"model":"anthropic.claude-haiku-4-5","max_tokens":16, +/// "messages":[{"role":"user","content":"Say ok"}]}' \ +/// https://bedrock-mantle..api.aws/anthropic/v1/messages +/// ``` +/// +/// Bedrock's Converse API was evaluated (live, 2026-07-16) and rejected: it +/// works on standard access, but it speaks AWS's own schema in both directions, +/// so it would replace these targeted fixups with a full Anthropic⇄Converse +/// codec (content taxonomy, toolSpec, synthesized message ids, a Converse-event +/// stream translation) while still needing the event-stream decoder, the system +/// fold, the server-tool filter, and the beta bridge in relocated form. +async fn proxy_bedrock_anthropic( + client: &reqwest::Client, + route: &GatewayRoute, + upstream_id: &str, + mut payload: Value, + headers: &HeaderMap, +) -> Result { + let region = route.region.as_deref().ok_or_else(|| missing_field(route, "region"))?; + + let obj = payload.as_object_mut().ok_or_else(|| { + AlienError::new(ErrorData::InvalidRequest { + message: "request body must be a JSON object".to_string(), + }) + })?; + // The model is in the URL and streaming is chosen by the URL suffix, so neither + // belongs in the body; Bedrock requires its own version marker there instead. + obj.remove("model"); + // Bedrock's schema rejects a body `stream` field, so it is removed here. + let stream = parse_stream_flag(obj.remove("stream"))?; + obj.insert("anthropic_version".to_string(), json!("bedrock-2023-05-31")); + + // Claude clients declare betas in the `anthropic-beta` HTTP header, but classic + // InvokeModel reads only the body's `anthropic_beta`. Bridge the Bedrock-known + // families across so a beta-gated tool we forward (computer_*, memory_*) arrives + // with the beta it needs — and drop the rest, which Bedrock's body validation + // rejects (see merge_beta_headers). + merge_beta_headers(obj, headers); + + // Bedrock's InvokeModel schema (pinned to `bedrock-2023-05-31`) predates the + // newest Anthropic Messages fields, so a latest client (Claude Code) sends fields + // it rejects. Drop the ones outside its schema so the request isn't a 400 — the + // gateway is bridging a protocol-version gap, not the raw native endpoint. + obj.remove("output_config"); + obj.remove("context_management"); + // Bedrock supports only `enabled`/`disabled` extended thinking; drop a newer mode + // (e.g. `adaptive`) rather than let Bedrock reject the whole request. + let thinking_unsupported = obj + .get("thinking") + .and_then(|t| t.get("type")) + .and_then(Value::as_str) + .is_some_and(|t| t != "enabled" && t != "disabled"); + if thinking_unsupported { + obj.remove("thinking"); + } + // Anthropic *server*-executed tool types (web_search, code_execution, web fetch, + // advisor) run on Anthropic's own API servers, which InvokeModel is not, so + // Bedrock rejects them; drop those and keep the families Bedrock hosts. Dropping + // (rather than a 400) is deliberate bridge behavior — Claude Code declares web + // tools by default, so rejecting would fail its every request — but it must stay + // visible in the logs and leave the body coherent: Bedrock rejects a `tool_choice` + // that forces a tool that is no longer declared, and an emptied `tools` array. + // A kept beta-gated family still needs its `anthropic_beta` entry, which + // merge_beta_headers has already bridged from the header above. Also strip + // `defer_loading`, a client-tool field Claude Code's on-demand tool loading adds + // that the pinned schema rejects as an extra input. + // + // Residue blocks a *previous* server-tool turn left in `messages` (e.g. + // `web_search_tool_result` from a conversation started on Anthropic's API) are + // deliberately NOT rewritten: Bedrock knows those block types and rejects foreign + // ones loudly (live-verified: Anthropic-issued `encrypted_content` fails its + // validation), and that 400 reaches the client via forward_response — a loud, + // honest failure, where stripping would silently alter the conversation. + let mut dropped_tools: Vec = Vec::new(); + let mut tools_remaining = true; + if let Some(tools) = obj.get_mut("tools").and_then(Value::as_array_mut) { + tools.retain(|tool| { + let keep = match tool.get("type").and_then(Value::as_str) { + // Plain client tools carry no type, or `custom`. + None | Some("custom") => true, + Some(tag) => BEDROCK_HOSTED_TOOL_PREFIXES.iter().any(|p| tag.starts_with(p)), + }; + if !keep { + let label = tool + .get("name") + .or_else(|| tool.get("type")) + .and_then(Value::as_str) + .unwrap_or("unnamed"); + dropped_tools.push(label.to_string()); + } + keep + }); + for tool in tools.iter_mut() { + if let Some(obj) = tool.as_object_mut() { + obj.remove("defer_loading"); + } + } + tools_remaining = !tools.is_empty(); + } + if !dropped_tools.is_empty() { + warn!( + binding = %route.name, + tools = %dropped_tools.join(", "), + "dropped Anthropic server-executed tools that Bedrock InvokeModel cannot serve" + ); + if !tools_remaining { + obj.remove("tools"); + obj.remove("tool_choice"); + } else { + let forces_dropped = obj + .get("tool_choice") + .and_then(|choice| choice.get("name")) + .and_then(Value::as_str) + .is_some_and(|name| dropped_tools.iter().any(|dropped| dropped == name)); + if forces_dropped { + obj.remove("tool_choice"); + } + } + } + // The pinned schema also predates mid-conversation `system` roles inside + // `messages` (top-level `system` is its only sanctioned spot) and enforces + // user/assistant alternation. Re-tag those turns as `user` where they stand — + // their position in the conversation is what carries the meaning — then fold + // same-role neighbors into one message so alternation still holds. + if let Some(messages) = obj.get_mut("messages").and_then(Value::as_array_mut) { + let originals = std::mem::take(messages); + for mut message in originals { + if message.get("role").and_then(Value::as_str) == Some("system") { + message["role"] = json!("user"); + } + let same_role_as_last = messages + .last() + .and_then(|previous| previous.get("role")) + .is_some_and(|role| Some(role) == message.get("role")); + if let Some(previous) = messages.last_mut().filter(|_| same_role_as_last) { + let addition = take_content_blocks(&mut message)?; + let merged = ensure_block_content(previous)?; + // A tool_use turn must be answered by tool_result blocks at the START + // of the next message (live-verified: Bedrock 400s on `[text, + // tool_result]`), so when the folded-in neighbor carries results — + // e.g. a downgraded system turn landed between a tool call and its + // result — they slot in right after any results already leading. + let (tool_results, rest): (Vec, Vec) = + addition.into_iter().partition(|block| { + block.get("type").and_then(Value::as_str) == Some("tool_result") + }); + let leading = merged + .iter() + .take_while(|block| { + block.get("type").and_then(Value::as_str) == Some("tool_result") + }) + .count(); + merged.splice(leading..leading, tool_results); + merged.extend(rest); + } else { + messages.push(message); + } + } + } + + let upstream_body = serde_json::to_vec(&payload) + .into_alien_error() + .context(ErrorData::Other { + message: "could not re-serialize the Bedrock request body".to_string(), + })?; + + let suffix = if stream { "invoke-with-response-stream" } else { "invoke" }; + let model_id = format!("{}.{}", bedrock_geo(region), upstream_id); + let base = route + .upstream_base_override + .clone() + .unwrap_or_else(|| format!("https://bedrock-runtime.{region}.amazonaws.com")); + let url = format!("{}/model/{}/{}", base.trim_end_matches('/'), model_id, suffix); + + let upstream = sign_and_execute(client, &route.cred, &url, "bedrock", upstream_body, &[]).await?; + + let status = + StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + + // Only a 2xx streaming reply is event-stream framed. A non-2xx (throttling, + // missing model access, bad request) and every non-streaming reply are plain + // JSON, so forward them untouched — the client sees the real status and body. + if !upstream.status().is_success() || !stream { + return forward_response(upstream); + } + + // Decode the event-stream frames into Anthropic SSE as they arrive (the decoder + // buffers across network chunks, so partial frames don't corrupt the output), + // then flush once the upstream closes: a stream that ended mid-frame surfaces a + // loud error via finish() instead of a silently truncated reply. + let sse = futures::stream::unfold( + (Box::pin(upstream.bytes_stream()), EventStreamToSse::default(), false), + |(mut body, mut decoder, done)| async move { + if done { + return None; + } + match body.next().await { + Some(Ok(bytes)) => { + Some((Ok(Bytes::from(decoder.push(&bytes))), (body, decoder, false))) + } + Some(Err(err)) => Some((Err(err), (body, decoder, true))), + // Upstream closed: emit the end-of-stream flush, then stop. + None => Some((Ok(Bytes::from(decoder.finish())), (body, decoder, true))), + } + }, + ); + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/event-stream") + .body(Body::from_stream(sse)) + .into_alien_error() + .context(ErrorData::Other { + message: "could not build the streamed response".to_string(), + }) +} + +/// Merge the request's `anthropic-beta` headers into the body's `anthropic_beta`. +/// The header may repeat and each value may be comma-separated; the body field takes +/// an array or a single string. Body entries are kept and duplicates dropped, so a +/// client may declare betas any of those ways. +/// +/// Only BEDROCK_BETA_PREFIXES families are bridged. Bedrock ignores unknown tags in +/// the *header* but validates the *body* list, so folding an Anthropic-API-side +/// marker across turns the whole request into a ValidationException. Body entries +/// stay unfiltered: a client authoring Bedrock-dialect JSON asked for exactly that +/// list and gets Bedrock's loud answer. +fn merge_beta_headers(obj: &mut Map, headers: &HeaderMap) { + let mut betas: Vec = match obj.get("anthropic_beta") { + Some(Value::Array(list)) => { + list.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect() + } + Some(Value::String(tag)) => vec![tag.clone()], + _ => Vec::new(), + }; + for beta in filtered_header_betas(headers) { + if !betas.iter().any(|existing| existing == &beta) { + betas.push(beta); + } + } + if !betas.is_empty() { + obj.insert("anthropic_beta".to_string(), json!(betas)); + } +} + +/// The client's `anthropic-beta` declarations that pass the allowlist. The header +/// may repeat and each value may be comma-separated. Filtering is an allowlist +/// because these endpoints validate what they are handed: an Anthropic-API-side +/// marker (notably `oauth-2025-04-20`, declared by every OAuth Claude Code +/// request) turns the whole request into a 400. Vertex and Foundry reuse the +/// Bedrock-verified families until their own live probes verify per-upstream +/// lists. +fn filtered_header_betas(headers: &HeaderMap) -> Vec { + let mut kept: Vec = Vec::new(); + let mut dropped: Vec = Vec::new(); + for value in headers.get_all("anthropic-beta") { + let Ok(raw) = value.to_str() else { continue }; + for beta in raw.split(',').map(str::trim).filter(|b| !b.is_empty()) { + if !BEDROCK_BETA_PREFIXES.iter().any(|p| beta.starts_with(p)) { + dropped.push(beta.to_string()); + continue; + } + if !kept.iter().any(|existing| existing == beta) { + kept.push(beta.to_string()); + } + } + } + if !dropped.is_empty() { + warn!( + betas = %dropped.join(", "), + "dropped anthropic-beta tags outside the allowlisted families" + ); + } + kept +} + +/// Normalize a message's `content` to a block array and hand the array back, so two +/// messages folding into one can concatenate their block lists. A string becomes a +/// single text block; an existing array is kept; any other shape (missing, null, an +/// object) is a malformed message the native Anthropic endpoint would reject — fail +/// loud rather than fold the turn into an empty array and answer a conversation the +/// client didn't send. +fn ensure_block_content(message: &mut Value) -> Result<&mut Vec> { + match message.get("content") { + Some(Value::Array(_)) => {} + Some(Value::String(text)) => { + message["content"] = json!([{ "type": "text", "text": text }]); + } + _ => { + return Err(AlienError::new(ErrorData::InvalidRequest { + message: "every message `content` must be a string or an array of blocks" + .to_string(), + })) + } + } + Ok(message["content"] + .as_array_mut() + .expect("content was just normalized to an array")) +} + +/// Take a message's content as a block list, leaving the message with an empty one. +fn take_content_blocks(message: &mut Value) -> Result> { + Ok(std::mem::take(ensure_block_content(message)?)) +} + +/// The cross-region inference-profile geo prefix for a Bedrock region. Claude on +/// Bedrock is invocable only through a geo profile (e.g. `us.anthropic.…`). +/// +/// us / us-gov regions keep their own geo. Every other commercial region routes via +/// the region-agnostic `global` profile: current-generation Claude models publish a +/// `global.` inference profile invocable from any commercial region (verified against +/// live Bedrock), and do NOT publish `eu.`/`apac.` profiles, so a per-continent +/// prefix would build a non-existent id. (An older model that publishes only a `us.` +/// profile, e.g. opus-4.1, stays us-region-only either way.) +fn bedrock_geo(region: &str) -> &'static str { + if region.starts_with("us-gov-") { + "us-gov" + } else if region.starts_with("us-") { + "us" + } else { + "global" + } +} + +/// Decoder turning Bedrock's `vnd.amazon.eventstream` framing into Anthropic SSE. +/// A normal chunk frame's payload is `{"bytes": base64()}`; +/// the decoded event carries a `type` we surface as the SSE `event:` name. Network +/// chunks can split or merge frames, so bytes are buffered until each frame is +/// whole. Frame parsing (prelude, headers, both CRC32 checks) is +/// aws-smithy-eventstream's `MessageFrameDecoder` — AWS's own decoder for this +/// wire format — so a corrupted or desynced stream fails its CRCs instead of +/// decoding to garbage. +#[derive(Default)] +struct EventStreamToSse { + buf: Vec, + /// Set once a frame fails to decode (a CRC mismatch or malformed prelude). + /// From then on the buffer can never be drained, so we stop parsing rather than + /// spin on it forever. + failed: bool, +} + +impl EventStreamToSse { + /// Append a network chunk and return the SSE for every frame it now completes. + fn push(&mut self, chunk: &[u8]) -> String { + if self.failed { + return String::new(); + } + self.buf.extend_from_slice(chunk); + let mut out = String::new(); + // A fresh decoder scans the buffer from the top each push, and only the bytes + // of fully decoded frames are drained: MessageFrameDecoder consumes a prelude + // into internal state before its frame completes, so reusing one across + // pushes would strand those bytes between the two buffers. + let mut decoder = MessageFrameDecoder::new(); + let mut cursor: &[u8] = &self.buf; + let mut consumed = 0; + loop { + match decoder.decode_frame(&mut cursor) { + Ok(DecodedFrame::Complete(message)) => { + consumed = self.buf.len() - cursor.len(); + out.push_str(&message_to_sse(&message)); + } + Ok(DecodedFrame::Incomplete) => break, + Err(_) => { + // A CRC mismatch or malformed prelude can never recover: the byte + // stream desynced. Surface it loudly and stop, rather than + // silently stall on bytes we can never drain (which would + // truncate the reply under an already-sent 200). + self.failed = true; + out.push_str(&error_sse("the model response stream could not be decoded")); + break; + } + } + } + self.buf.drain(0..consumed); + out + } + + /// Flush at end of stream. A non-empty buffer here means the upstream closed + /// mid-frame (a truncated or desynced stream), so surface a loud error rather + /// than drop the tail; a clean boundary (empty buffer) emits nothing. + fn finish(&mut self) -> String { + if self.failed || self.buf.is_empty() { + return String::new(); + } + self.failed = true; + error_sse("the model response stream ended before the final frame completed") + } +} + +/// One event-stream message rendered as the Anthropic SSE the client expects. +/// +/// A normal chunk wraps the event as `{"bytes": base64(...)}`. Anything else on an +/// InvokeModelWithResponseStream reply is an exception frame: Bedrock signals +/// mid-stream failures (throttlingException, modelStreamErrorException, +/// internalServerException) this way, with the exception body as the raw payload. +/// Such a frame is surfaced as an Anthropic `error` SSE event rather than dropped, +/// because dropping it would truncate the reply under an already-sent 200 with no +/// error reaching the client. +fn message_to_sse(message: &Message) -> String { + let outer: Option = serde_json::from_slice(message.payload()).ok(); + if let Some(sse) = outer.as_ref().and_then(chunk_to_sse) { + return sse; + } + // Exception / error frame: forward Bedrock's own message so the client sees why. + let message = outer + .as_ref() + .and_then(|o| o.get("message")) + .and_then(Value::as_str) + .unwrap_or("the model returned an error mid-stream"); + error_sse(message) +} + +/// A normal `{"bytes": base64()}` chunk rendered as its SSE line, +/// or `None` if the frame is not a well-formed chunk. +fn chunk_to_sse(outer: &Value) -> Option { + let event_bytes = STANDARD.decode(outer.get("bytes")?.as_str()?).ok()?; + let event: Value = serde_json::from_slice(&event_bytes).ok()?; + let event_type = event.get("type")?.as_str()?; + let data = std::str::from_utf8(&event_bytes).ok()?; + Some(format!("event: {event_type}\ndata: {data}\n\n")) +} + +/// An Anthropic `error` SSE event carrying `message`, so a mid-stream failure +/// reaches the client as a loud error instead of a silently truncated reply. +fn error_sse(message: &str) -> String { + let event = json!({ "type": "error", "error": { "type": "api_error", "message": message } }); + format!("event: error\ndata: {event}\n\n") +} + +#[cfg(test)] +mod tests { + use std::net::Ipv4Addr; + + use aws_credential_types::provider::SharedCredentialsProvider; + use aws_credential_types::Credentials; + use aws_smithy_eventstream::frame::write_message_to; + use httpmock::prelude::*; + + use super::*; + use crate::creds::{AwsSigV4Cred, BearerTokenCred}; + + fn test_aws_cred() -> AmbientCred { + let creds = Credentials::new( + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + None, + None, + "test", + ); + AmbientCred::Aws(AwsSigV4Cred::with_provider( + "us-east-2", + SharedCredentialsProvider::new(creds), + )) + } + + async fn serve(router: Router) -> String { + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind test server"); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + url + } + + fn aws_route(upstream: &str) -> GatewayRoute { + GatewayRoute { + name: "llm".to_string(), + cloud: Platform::Aws, + region: Some("us-east-2".to_string()), + project: None, + azure_endpoint: None, + cred: test_aws_cred(), + upstream_base_override: Some(upstream.to_string()), + } + } + + fn gcp_route(location: &str) -> GatewayRoute { + GatewayRoute { + name: "llm".to_string(), + cloud: Platform::Gcp, + region: Some(location.to_string()), + project: Some("my-proj".to_string()), + azure_endpoint: None, + cred: AmbientCred::Bearer(BearerTokenCred::static_token("t")), + upstream_base_override: None, + } + } + + #[test] + fn gcp_vertex_url_regional_vs_global() { + // A region prefixes the host; `global` uses the un-prefixed host. The path always + // carries `locations/{location}`. + let (regional, _) = upstream_target(&gcp_route("us-central1"), Protocol::OpenAi).unwrap(); + assert_eq!( + regional, + "https://us-central1-aiplatform.googleapis.com/v1/projects/my-proj/locations/us-central1/endpoints/openapi/chat/completions" + ); + let (global, _) = upstream_target(&gcp_route("global"), Protocol::OpenAi).unwrap(); + assert_eq!( + global, + "https://aiplatform.googleapis.com/v1/projects/my-proj/locations/global/endpoints/openapi/chat/completions" + ); + } + + #[tokio::test] + async fn vertex_claude_rewrites_body_and_url() { + // Claude on Vertex: the model travels in the URL (as the Vertex `@date` id, + // resolved from Claude Code's dashed spelling), the body carries Vertex's + // version marker instead of a `model`, and the bearer credential rides along. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/projects/my-proj/locations/us-east5/publishers/anthropic/models/claude-haiku-4-5@20251001:rawPredict") + .matches(|req: &HttpMockRequest| { + let body: Value = + serde_json::from_slice(req.body.as_deref().unwrap_or_default()) + .unwrap_or(Value::Null); + body.get("model").is_none() + && body["anthropic_version"] == "vertex-2023-10-16" + }) + .matches(|req: &HttpMockRequest| { + req.headers.as_ref().is_some_and(|headers| { + headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("authorization") + && value.starts_with("Bearer ") + }) + }) + }); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"id":"msg_1","content":[{"type":"text","text":"pong"}]}"#); + }) + .await; + + let mut route = gcp_route("us-east5"); + route.upstream_base_override = Some(server.base_url()); + let url = serve(build_router(vec![route])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + let text = resp.text().await.unwrap(); + assert!(text.contains("\"pong\""), "upstream body must pass through: {text}"); + mock.assert_async().await; + } + + #[tokio::test] + async fn vertex_claude_streaming_uses_stream_verb() { + // `stream: true` picks the `:streamRawPredict` verb, and Vertex's native + // Anthropic SSE passes through byte-for-byte — no event-stream decode. + let sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\n\ + event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"; + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/projects/my-proj/locations/us-east5/publishers/anthropic/models/claude-opus-4-8:streamRawPredict"); + then.status(200) + .header("content-type", "text/event-stream") + .body(sse); + }) + .await; + + let mut route = gcp_route("us-east5"); + route.upstream_base_override = Some(server.base_url()); + let url = serve(build_router(vec![route])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({ + "model": "claude-opus-4.8", + "stream": true, + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + assert_eq!(resp.headers().get("content-type").unwrap(), "text/event-stream"); + assert_eq!(resp.text().await.unwrap(), sse, "SSE must stream through byte-for-byte"); + mock.assert_async().await; + } + + #[tokio::test] + async fn vertex_claude_rejects_non_boolean_stream() { + // A malformed `stream` picks between two upstream verbs, so it must be a + // loud 400, not a coerced guess. + let mut route = gcp_route("us-east5"); + route.upstream_base_override = Some("http://unused.invalid".to_string()); + let url = serve(build_router(vec![route])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({"model": "claude-opus-4.8", "stream": "yes", "messages": []})) + .send() + .await + .expect("proxy request"); + assert_eq!(resp.status(), 400); + assert!( + resp.text().await.unwrap().contains("GATEWAY_INVALID_REQUEST"), + "must fail on the stream-validation path, not some other 400" + ); + } + + #[tokio::test] + async fn vertex_claude_forwards_allowlisted_betas_as_header() { + // Vertex is the native Messages API: betas ride the standard header, not a + // body field. An allowlisted family crosses over; the OAuth marker every + // wrapped Claude Code session declares does not. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .header("anthropic-beta", "computer-use-2025-01-24") + .matches(|req: &HttpMockRequest| { + let body: Value = + serde_json::from_slice(req.body.as_deref().unwrap_or_default()) + .unwrap_or(Value::Null); + // Betas do NOT go in the body for Vertex. + body.get("anthropic_beta").is_none() + }); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"id":"msg_1","content":[]}"#); + }) + .await; + + let mut route = gcp_route("us-east5"); + route.upstream_base_override = Some(server.base_url()); + let url = serve(build_router(vec![route])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .header("anthropic-beta", "computer-use-2025-01-24, oauth-2025-04-20") + .json(&json!({"model": "claude-opus-4.8", "max_tokens": 16, "messages": []})) + .send() + .await + .expect("proxy request"); + assert_eq!(resp.status(), 200); + mock.assert_async().await; + } + + fn azure_route(endpoint: &str) -> GatewayRoute { + GatewayRoute { + name: "llm".to_string(), + cloud: Platform::Azure, + region: None, + project: None, + azure_endpoint: Some(endpoint.to_string()), + cred: AmbientCred::Bearer(BearerTokenCred::static_token("t")), + upstream_base_override: None, + } + } + + #[tokio::test] + async fn foundry_claude_rewrites_model_and_sends_version_header() { + // Claude on Foundry: the model stays in the body, rewritten to the Foundry + // deployment name, on the `/anthropic/v1` path with the version header and + // the bearer credential. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/anthropic/v1/messages") + .header("anthropic-version", "2023-06-01") + .matches(|req: &HttpMockRequest| { + let body: Value = + serde_json::from_slice(req.body.as_deref().unwrap_or_default()) + .unwrap_or(Value::Null); + body["model"] == "claude-opus-4-8" + }) + .matches(|req: &HttpMockRequest| { + req.headers.as_ref().is_some_and(|headers| { + headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("authorization") + && value.starts_with("Bearer ") + }) + }) + }); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"id":"msg_1","content":[{"type":"text","text":"pong"}]}"#); + }) + .await; + + let url = serve(build_router(vec![azure_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({ + "model": "claude-opus-4.8", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + let text = resp.text().await.unwrap(); + assert!(text.contains("\"pong\""), "upstream body must pass through: {text}"); + mock.assert_async().await; + } + + #[tokio::test] + async fn foundry_claude_forwards_allowlisted_betas_as_header() { + // Foundry takes the standard header; the allowlist still drops the OAuth + // marker so the request is not rejected wholesale. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/anthropic/v1/messages") + .header("anthropic-beta", "computer-use-2025-01-24"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"id":"msg_1","content":[]}"#); + }) + .await; + + let url = serve(build_router(vec![azure_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .header("anthropic-beta", "computer-use-2025-01-24, oauth-2025-04-20") + .json(&json!({"model": "claude-opus-4.8", "max_tokens": 16, "messages": []})) + .send() + .await + .expect("proxy request"); + assert_eq!(resp.status(), 200); + mock.assert_async().await; + } + + #[tokio::test] + async fn rewrites_model_signs_and_returns_body() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/openai/v1/chat/completions") + // The gateway rewrote gpt-oss-20b to the upstream id and injected a credential. + .body_contains("openai.gpt-oss-20b-1:0") + .header_exists("authorization"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"id":"cmpl-1","choices":[{"message":{"content":"pong"}}]}"#); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/chat/completions")) + .json(&json!({"model":"gpt-oss-20b","messages":[{"role":"user","content":"hi"}]})) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + let text = resp.text().await.unwrap(); + assert!(text.contains("\"pong\""), "upstream body must pass through: {text}"); + // The mock only matches when the body carries the rewritten upstream id and an + // Authorization header, so a hit proves the model rewrite and cred injection. + mock.assert_async().await; + } + + #[tokio::test] + async fn streams_sse_through_unchanged() { + let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"po\"}}]}\n\n\ + data: {\"choices\":[{\"delta\":{\"content\":\"ng\"}}]}\n\n\ + data: [DONE]\n\n"; + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST).path("/openai/v1/chat/completions"); + then.status(200) + .header("content-type", "text/event-stream") + .body(sse); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/chat/completions")) + .json(&json!({"model":"gpt-oss-20b","stream":true,"messages":[]})) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "text/event-stream" + ); + let body = resp.text().await.unwrap(); + assert_eq!(body, sse, "SSE must stream through byte-for-byte"); + mock.assert_async().await; + } + + /// Build a single `vnd.amazon.eventstream` frame wrapping `event_json`, the way + /// Bedrock's invoke-with-response-stream does: payload `{"bytes": base64(event)}`. + /// Encoded with aws-smithy-eventstream so the CRCs are real — the decoder + /// validates them. + fn eventstream_frame(event_json: &str) -> Vec { + let payload = format!(r#"{{"bytes":"{}"}}"#, STANDARD.encode(event_json)); + raw_payload_frame(&payload) + } + + /// Build an event-stream frame whose payload is `payload` verbatim, with no + /// `{"bytes": ...}` wrapper — the shape of a Bedrock mid-stream exception frame. + fn raw_payload_frame(payload: &str) -> Vec { + let message = Message::new(Bytes::from(payload.to_string())); + let mut frame = Vec::new(); + write_message_to(&message, &mut frame).expect("encode test frame"); + frame + } + + #[test] + fn decoder_surfaces_bedrock_exception_frame_as_error() { + // A Bedrock mid-stream exception frame's payload is the raw exception JSON, + // NOT wrapped in {"bytes": ...}. It must surface as an Anthropic error event + // rather than be dropped, which would truncate the reply under a 200. + let mut decoder = EventStreamToSse::default(); + let out = decoder.push(&raw_payload_frame(r#"{"message":"Model stream timed out"}"#)); + assert!(out.contains("event: error"), "exception frame must surface an error: {out}"); + assert!( + out.contains("Model stream timed out"), + "the upstream error message must reach the client: {out}" + ); + } + + #[test] + fn decoder_emits_normal_chunk_then_surfaces_a_following_exception() { + let mut decoder = EventStreamToSse::default(); + let mut bytes = + eventstream_frame(r#"{"type":"content_block_delta","delta":{"text":"hi"}}"#); + bytes.extend_from_slice(&raw_payload_frame(r#"{"message":"throttled"}"#)); + let out = decoder.push(&bytes); + assert!(out.contains("event: content_block_delta"), "the normal delta must decode: {out}"); + assert!( + out.contains("event: error") && out.contains("throttled"), + "a trailing exception frame must still surface: {out}" + ); + } + + #[test] + fn decoder_fails_loud_on_desynced_frame() { + // A prelude whose CRC does not match (here: an impossible declared length + // with zeroed CRCs) can never be valid; the decoder must emit an error and + // stop, not silently stall on undrainable bytes. + let mut decoder = EventStreamToSse::default(); + let mut bytes = 8u32.to_be_bytes().to_vec(); // total=8 (<16): impossible + bytes.extend_from_slice(&[0u8; 12]); + let out = decoder.push(&bytes); + assert!(out.contains("event: error"), "a desynced frame must surface an error: {out}"); + // A desync is unrecoverable, so further input is ignored rather than decoded + // mid-stream as if nothing were wrong. + let after = decoder.push(&eventstream_frame(r#"{"type":"message_stop"}"#)); + assert_eq!(after, "", "decoder must stop after a desync"); + } + + #[test] + fn decoder_fails_loud_on_corrupted_frame() { + // A bit-flip inside a valid frame fails the CRC check: the corruption must + // surface as an error, not decode to garbage misattributed to Bedrock. + let mut bytes = eventstream_frame(r#"{"type":"content_block_delta","delta":{"text":"hi"}}"#); + let middle = bytes.len() / 2; + bytes[middle] ^= 0xFF; + let mut decoder = EventStreamToSse::default(); + let out = decoder.push(&bytes); + assert!(out.contains("event: error"), "a corrupted frame must surface an error: {out}"); + } + + #[test] + fn decoder_flushes_incomplete_trailing_frame_as_error() { + // The upstream closed after only part of a frame arrived (a truncated stream); + // finish() must surface a loud error rather than drop the buffered tail. + let mut decoder = EventStreamToSse::default(); + let full = eventstream_frame(r#"{"type":"content_block_delta","delta":{"text":"hi"}}"#); + let partial = &full[..full.len() - 5]; + assert_eq!(decoder.push(partial), "", "an incomplete frame emits nothing until it completes"); + let flushed = decoder.finish(); + assert!( + flushed.contains("event: error"), + "EOF with a buffered partial frame must surface an error: {flushed}" + ); + } + + #[test] + fn decoder_finish_is_silent_on_a_clean_boundary() { + // Every frame consumed: finish() must NOT inject a spurious error event. + let mut decoder = EventStreamToSse::default(); + let out = decoder.push(&eventstream_frame(r#"{"type":"message_stop"}"#)); + assert!(out.contains("event: message_stop")); + assert_eq!(decoder.finish(), "", "a clean stream end must not emit an error"); + } + + #[test] + fn ensure_block_content_normalizes_valid_shapes_and_rejects_the_rest() { + // The same-role fold extends the previous message's content ARRAY, so + // ensure_block_content must yield an array for the two valid shapes — and + // fail loud on a malformed one instead of folding the turn into []. + let mut s = json!({"role": "user", "content": "hi"}); + ensure_block_content(&mut s).expect("string content is valid"); + assert_eq!(s["content"], json!([{"type": "text", "text": "hi"}])); + + let mut arr = json!({"role": "user", "content": [{"type": "text", "text": "x"}]}); + ensure_block_content(&mut arr).expect("array content is valid"); + assert_eq!(arr["content"], json!([{"type": "text", "text": "x"}])); + + let mut missing = json!({"role": "user"}); + ensure_block_content(&mut missing).expect_err("missing content must be rejected"); + + let mut object = json!({"role": "user", "content": {"type": "text", "text": "hi"}}); + ensure_block_content(&mut object).expect_err("object content must be rejected"); + } + + #[test] + fn bedrock_geo_routes_non_us_regions_via_global() { + assert_eq!(bedrock_geo("us-east-2"), "us"); + assert_eq!(bedrock_geo("us-west-2"), "us"); + assert_eq!(bedrock_geo("us-gov-west-1"), "us-gov"); + assert_eq!(bedrock_geo("eu-west-1"), "global"); + assert_eq!(bedrock_geo("ap-southeast-2"), "global"); + assert_eq!(bedrock_geo("ca-central-1"), "global"); + assert_eq!(bedrock_geo("sa-east-1"), "global"); + assert_eq!(bedrock_geo("mx-central-1"), "global"); + } + + #[tokio::test] + async fn claude_streams_through_bedrock_invoke_as_sse() { + // A Claude model on an AWS binding must route to classic InvokeModel — the + // model as a geo inference profile in the URL, no model/stream in the body — + // and the event-stream reply must be decoded back into Anthropic SSE. + let event = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}"#; + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream") + .body_contains("bedrock-2023-05-31") + .header_exists("authorization"); + then.status(200) + .header("content-type", "application/vnd.amazon.eventstream") + .body(eventstream_frame(event)); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({"model":"claude-haiku-4.5","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"hi"}]})) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "text/event-stream" + ); + let body = resp.text().await.unwrap(); + assert!( + body.contains("event: content_block_delta"), + "event-stream must be decoded to Anthropic SSE: {body}" + ); + assert!(body.contains(r#""text":"pong""#), "delta text must survive: {body}"); + mock.assert_async().await; + } + + #[tokio::test] + async fn claude_non_streaming_returns_json_through_invoke() { + // Without stream, a Claude request must hit the classic InvokeModel `invoke` + // suffix (not invoke-with-response-stream) and its JSON reply passes straight + // through untouched — the event-stream decoder is only for the streaming path. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke") + .body_contains("bedrock-2023-05-31") + .header_exists("authorization"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"type":"message","content":[{"type":"text","text":"pong"}]}"#); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/json" + ); + let text = resp.text().await.unwrap(); + assert!(text.contains(r#""pong""#), "non-streaming JSON must pass through: {text}"); + mock.assert_async().await; + } + + #[tokio::test] + async fn claude_stream_truncated_midframe_surfaces_error_to_client() { + // The upstream sends a complete HTTP body that ends mid event-stream frame + // (a truncated stream). End to end, the client must receive an `event: error` + // rather than a stream that just stops. This exercises the real + // Body::from_stream + unfold finish() plumbing that the decoder unit tests + // (which call finish() directly) do not cover. + let full = eventstream_frame(r#"{"type":"content_block_delta","delta":{"text":"partial"}}"#); + let truncated = full[..full.len() - 6].to_vec(); // an incomplete final frame + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST).path( + "/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream", + ); + then.status(200) + .header("content-type", "application/vnd.amazon.eventstream") + .body(truncated); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({"model":"claude-haiku-4.5","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"hi"}]})) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + let body = resp.text().await.unwrap(); + assert!( + body.contains("event: error"), + "a truncated upstream stream must surface an error to the client: {body}" + ); + mock.assert_async().await; + } + + #[tokio::test] + async fn bedrock_path_drops_fields_bedrock_rejects() { + // A latest Claude Code body carries newer Anthropic fields that Bedrock's + // classic schema rejects; the gateway must strip them (the mock only matches, + // and thus 200s, when they are absent from the upstream body). + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream") + .matches(|req: &HttpMockRequest| { + let body = req + .body + .as_deref() + .map(String::from_utf8_lossy) + .unwrap_or_default(); + !body.contains("output_config") + && !body.contains("context_management") + && !body.contains("adaptive") + }); + then.status(200) + .header("content-type", "application/vnd.amazon.eventstream") + .body(eventstream_frame(r#"{"type":"message_stop"}"#)); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "stream": true, + "max_tokens": 16, + "output_config": {"effort": "xhigh"}, + "context_management": {"edits": []}, + "thinking": {"type": "adaptive"}, + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + mock.assert_async().await; + } + + #[tokio::test] + async fn bedrock_path_drops_server_tools_it_cannot_host() { + // Anthropic *server*-executed tools (advisor, web search) run on Anthropic's + // API servers; Bedrock rejects their tags. Client tools and the + // client-executed types Bedrock DOES host (text editor, computer use, …) + // must survive untouched. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream") + .matches(|req: &HttpMockRequest| { + let body: Value = serde_json::from_slice(req.body.as_deref().unwrap_or_default()) + .unwrap_or_default(); + let tools = body["tools"].as_array().cloned().unwrap_or_default(); + // read_file + text_editor + computer survive; advisor + web_search drop. + tools.len() == 3 + && tools[0]["name"] == "read_file" + // `defer_loading` stripped from the surviving client tool. + && tools[0].get("defer_loading").is_none() + && tools[1]["type"] == "text_editor_20250728" + && tools[2]["type"] == "computer_20250124" + }); + then.status(200) + .header("content-type", "application/vnd.amazon.eventstream") + .body(eventstream_frame(r#"{"type":"message_stop"}"#)); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "stream": true, + "max_tokens": 16, + "tools": [ + {"name": "read_file", "description": "reads", "input_schema": {"type": "object"}, "defer_loading": true}, + {"type": "text_editor_20250728", "name": "str_replace_based_edit_tool"}, + {"type": "computer_20250124", "name": "computer"}, + {"type": "advisor_20260301", "name": "advisor"}, + {"type": "web_search_20250305", "name": "web_search"} + ], + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + mock.assert_async().await; + } + + #[tokio::test] + async fn bedrock_path_bridges_the_anthropic_beta_header_into_the_body() { + // Clients declare betas as the `anthropic-beta` HTTP header, but classic + // InvokeModel reads only the body's `anthropic_beta`. Without the bridge, a + // forwarded beta-gated tool (computer_*) reaches Bedrock with no beta and 400s. + // A body-declared beta must survive alongside the bridged one, and header tags + // outside BEDROCK_BETA_PREFIXES (`oauth-2025-04-20` is on every OAuth Claude + // Code request; Bedrock rejects it as "invalid beta flag") must NOT be bridged. + // The mock only matches — and so only 200s — when the kept betas and the tool + // arrived and the rejected tags did not. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream") + .matches(|req: &HttpMockRequest| { + let body: Value = serde_json::from_slice(req.body.as_deref().unwrap_or_default()) + .unwrap_or_default(); + let betas = body["anthropic_beta"].as_array().cloned().unwrap_or_default(); + let tools = body["tools"].as_array().cloned().unwrap_or_default(); + betas.iter().any(|b| b == "context-management-2025-06-27") + && betas.iter().any(|b| b == "computer-use-2025-01-24") + && !betas.iter().any(|b| b == "oauth-2025-04-20") + && !betas.iter().any(|b| b == "tool-search-2025-10-02") + && tools.len() == 1 + && tools[0]["type"] == "computer_20250124" + }); + then.status(200) + .header("content-type", "application/vnd.amazon.eventstream") + .body(eventstream_frame(r#"{"type":"message_stop"}"#)); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .header( + "anthropic-beta", + "computer-use-2025-01-24,oauth-2025-04-20,tool-search-2025-10-02", + ) + .json(&json!({ + "model": "claude-haiku-4.5", + "stream": true, + "max_tokens": 16, + "anthropic_beta": ["context-management-2025-06-27"], + "tools": [{"type": "computer_20250124", "name": "computer"}], + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + mock.assert_async().await; + } + + #[tokio::test] + async fn bedrock_path_downgrades_system_role_messages() { + // Claude Code (mid-conversation-system beta) puts `role:"system"` turns + // inside `messages`; Bedrock's pinned schema allows only user/assistant + // there and enforces alternation. The gateway must re-tag the turn as + // `user` in place and fold it into its same-role neighbor. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream") + .matches(|req: &HttpMockRequest| { + let body: Value = serde_json::from_slice(req.body.as_deref().unwrap_or_default()) + .unwrap_or_default(); + let messages = body["messages"].as_array().cloned().unwrap_or_default(); + // One merged user turn: original user text + the downgraded + // system turn's text, in conversation order. + messages.len() == 1 + && messages[0]["role"] == "user" + && messages[0]["content"][0]["text"] == "hi" + && messages[0]["content"][1]["text"] == "hook output" + }); + then.status(200) + .header("content-type", "application/vnd.amazon.eventstream") + .body(eventstream_frame(r#"{"type":"message_stop"}"#)); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "stream": true, + "max_tokens": 16, + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "system", "content": "hook output"} + ] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + mock.assert_async().await; + } + + #[tokio::test] + async fn bedrock_path_folds_tool_results_ahead_of_downgraded_system_text() { + // A hook can emit a system turn between a tool call and its result: + // [assistant(tool_use), system(text), user(tool_result)]. The fold merges + // the downgraded system turn with the tool_result turn — and the result + // block must lead the merged message (live-verified: Bedrock rejects + // `[text, tool_result]` with "'tool_use' ids were found without + // 'tool_result' blocks immediately after"). + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream") + .matches(|req: &HttpMockRequest| { + let body: Value = serde_json::from_slice(req.body.as_deref().unwrap_or_default()) + .unwrap_or_default(); + let messages = body["messages"].as_array().cloned().unwrap_or_default(); + messages.len() == 3 + && messages[2]["role"] == "user" + && messages[2]["content"][0]["type"] == "tool_result" + && messages[2]["content"][1]["text"] == "hook output" + }); + then.status(200) + .header("content-type", "application/vnd.amazon.eventstream") + .body(eventstream_frame(r#"{"type":"message_stop"}"#)); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "stream": true, + "max_tokens": 16, + "messages": [ + {"role": "user", "content": "What time is it?"}, + {"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_time", "input": {}}]}, + {"role": "system", "content": "hook output"}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "12:00"}]} + ] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + mock.assert_async().await; + } + + #[tokio::test] + async fn bedrock_path_drops_tool_choice_with_the_last_server_tool() { + // When every declared tool is server-executed, stripping them leaves + // `tools: []` plus a tool_choice forcing a tool that no longer exists — + // both of which Bedrock rejects outright. The whole pair must go. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream") + .matches(|req: &HttpMockRequest| { + let body: Value = serde_json::from_slice(req.body.as_deref().unwrap_or_default()) + .unwrap_or_default(); + body.get("tools").is_none() && body.get("tool_choice").is_none() + }); + then.status(200) + .header("content-type", "application/vnd.amazon.eventstream") + .body(eventstream_frame(r#"{"type":"message_stop"}"#)); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "stream": true, + "max_tokens": 16, + "tools": [{"type": "web_search_20250305", "name": "web_search"}], + "tool_choice": {"type": "tool", "name": "web_search"}, + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + mock.assert_async().await; + } + + #[tokio::test] + async fn bedrock_path_rejects_a_non_boolean_stream_flag() { + // `stream` chooses between two upstream endpoints; a malformed value used + // to be coerced to `false`, answering an SSE client with a JSON body it + // reads as a hang. It must be a loud 400 instead. + let server = MockServer::start_async().await; + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "stream": "true", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 400); + let body = resp.text().await.expect("response body"); + assert!( + body.contains("GATEWAY_INVALID_REQUEST"), + "the 400 must be the gateway's own validation error: {body}" + ); + } + + #[tokio::test] + async fn bedrock_path_keeps_a_string_form_body_beta_alongside_header_betas() { + // The body's `anthropic_beta` also takes a single string; merging header + // betas used to overwrite that form entirely, silently dropping the beta + // the client's tool depended on. + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream") + .matches(|req: &HttpMockRequest| { + let body: Value = serde_json::from_slice(req.body.as_deref().unwrap_or_default()) + .unwrap_or_default(); + let betas = body["anthropic_beta"].as_array().cloned().unwrap_or_default(); + betas.iter().any(|b| b == "context-management-2025-06-27") + && betas.iter().any(|b| b == "computer-use-2025-01-24") + }); + then.status(200) + .header("content-type", "application/vnd.amazon.eventstream") + .body(eventstream_frame(r#"{"type":"message_stop"}"#)); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/messages")) + .header("anthropic-beta", "computer-use-2025-01-24") + .json(&json!({ + "model": "claude-haiku-4.5", + "stream": true, + "max_tokens": 16, + "anthropic_beta": "context-management-2025-06-27", + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + mock.assert_async().await; + } + + #[tokio::test] + async fn responses_pass_through_to_mantle() { + // Codex's /v1/responses must forward byte-for-byte to the mantle Responses + // endpoint with the model id rewritten and a SigV4 credential attached, and + // the Responses SSE must come back unchanged. + let sse = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"po\"}\n\n\ + data: {\"type\":\"response.completed\"}\n\n"; + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/responses") + // Mantle's Responses id drops the chat endpoint's version suffix. + .body_contains("\"openai.gpt-oss-20b\"") + // The SigV4 credential must be scoped to the bedrock-mantle service, + // not plain bedrock: mantle rejects a signature scoped to the wrong + // service. The scope segment appears verbatim in the credential. + .matches(|req: &HttpMockRequest| { + req.headers.as_ref().is_some_and(|headers| { + headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("authorization") + && value.contains("/bedrock-mantle/") + }) + }) + }); + then.status(200) + .header("content-type", "text/event-stream") + .body(sse); + }) + .await; + + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/responses")) + .json(&json!({"model":"gpt-oss-20b","stream":true,"input":[{"role":"user","content":"hi"}]})) + .send() + .await + .expect("proxy request"); + + assert_eq!(resp.status(), 200); + assert_eq!(resp.text().await.unwrap(), sse, "Responses SSE must pass through byte-for-byte"); + mock.assert_async().await; + } + + #[tokio::test] + async fn claude_over_responses_is_404() { + // Claude on mantle is Messages-only; a Claude id over /v1/responses must be + // rejected by the gateway, not forwarded. + let server = MockServer::start_async().await; + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/responses")) + .json(&json!({"model":"claude-haiku-4.5","input":[{"role":"user","content":"hi"}]})) + .send() + .await + .expect("proxy request"); + assert_eq!(resp.status(), 404); + // The mock upstream answers unmatched requests with its own 404, so the + // status alone cannot prove the gateway rejected the model rather than + // forwarding the request — the body must carry the gateway's error code. + let body = resp.text().await.expect("response body"); + assert!( + body.contains("GATEWAY_MODEL_NOT_AVAILABLE"), + "the 404 must be the gateway's own rejection, not a forwarded upstream 404: {body}" + ); + } + + #[tokio::test] + async fn unknown_model_is_404() { + let server = MockServer::start_async().await; + let url = serve(build_router(vec![aws_route(&server.base_url())])).await; + let resp = reqwest::Client::new() + .post(format!("{url}/llm/v1/chat/completions")) + .json(&json!({"model":"not-a-real-model","messages":[]})) + .send() + .await + .expect("proxy request"); + assert_eq!(resp.status(), 404); + let body = resp.text().await.expect("response body"); + assert!( + body.contains("GATEWAY_MODEL_NOT_AVAILABLE"), + "the 404 must be the gateway's own rejection, not a forwarded upstream 404: {body}" + ); + } + + #[tokio::test] + async fn models_lists_the_clouds_catalog() { + let url = serve(build_router(vec![aws_route("https://unused.example")])).await; + let resp = reqwest::get(format!("{url}/llm/v1/models")).await.unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["object"], "list"); + let ids: Vec<&str> = body["data"] + .as_array() + .unwrap() + .iter() + .map(|m| m["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&"gpt-oss-20b"), "AWS catalog must include gpt-oss-20b: {ids:?}"); + assert!(ids.contains(&"claude-opus-4.8"), "AWS catalog must include Claude: {ids:?}"); + } +} diff --git a/crates/alien-gateway/tests/integration.rs b/crates/alien-gateway/tests/integration.rs new file mode 100644 index 000000000..d6b697071 --- /dev/null +++ b/crates/alien-gateway/tests/integration.rs @@ -0,0 +1,155 @@ +//! End-to-end gateway routing across two clouds with mocked upstreams. +//! +//! Builds the real router with an AWS binding and an Azure binding pointed at two +//! mock upstream servers, then drives requests through the running loopback server +//! and asserts each is routed to the right upstream with the model id rewritten (per +//! the alien-core catalog), an ambient auth header injected, and the body streamed +//! back unchanged. Credentials are static (no metadata/network) so the test is +//! hermetic; the live ambient-credential resolution is exercised separately. + +use std::net::Ipv4Addr; + +use alien_core::Platform; +use alien_gateway::{build_router, AmbientCred, AwsSigV4Cred, BearerTokenCred, GatewayRoute}; +use aws_credential_types::provider::SharedCredentialsProvider; +use aws_credential_types::Credentials; +use httpmock::prelude::*; +use serde_json::{json, Value}; + +fn aws_cred() -> AmbientCred { + let creds = Credentials::new( + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + None, + None, + "test", + ); + AmbientCred::Aws(AwsSigV4Cred::with_provider( + "us-east-2", + SharedCredentialsProvider::new(creds), + )) +} + +async fn serve(router: axum::Router) -> String { + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind test server"); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + url +} + +#[tokio::test] +async fn routes_two_clouds_with_rewrite_auth_and_passthrough() { + let aws_upstream = MockServer::start_async().await; + let aws_mock = aws_upstream + .mock_async(|when, then| { + when.method(POST) + .path("/openai/v1/chat/completions") + // Rewritten to the upstream id, and SigV4-signed. + .body_contains("openai.gpt-oss-20b-1:0") + .header_exists("authorization"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"id":"aws","choices":[{"message":{"content":"aws-pong"}}]}"#); + }) + .await; + + let azure_upstream = MockServer::start_async().await; + let azure_mock = azure_upstream + .mock_async(|when, then| { + when.method(POST) + .path("/openai/v1/chat/completions") + .body_contains("gpt-4.1") + // The static bearer token is injected verbatim. + .header("authorization", "Bearer test-azure-token"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"id":"az","choices":[{"message":{"content":"az-pong"}}]}"#); + }) + .await; + + let routes = vec![ + GatewayRoute { + name: "llm".to_string(), + cloud: Platform::Aws, + region: Some("us-east-2".to_string()), + project: None, + azure_endpoint: None, + cred: aws_cred(), + upstream_base_override: Some(aws_upstream.base_url()), + }, + GatewayRoute { + name: "azllm".to_string(), + cloud: Platform::Azure, + region: None, + project: None, + azure_endpoint: Some(azure_upstream.base_url()), + cred: AmbientCred::Bearer(BearerTokenCred::static_token("test-azure-token")), + upstream_base_override: Some(azure_upstream.base_url()), + }, + ]; + + let base = serve(build_router(routes)).await; + let client = reqwest::Client::new(); + + // AWS binding: gpt-oss-20b -> openai.gpt-oss-20b-1:0 on the AWS upstream. + let aws_resp = client + .post(format!("{base}/llm/v1/chat/completions")) + .json(&json!({"model":"gpt-oss-20b","messages":[{"role":"user","content":"hi"}]})) + .send() + .await + .expect("aws request"); + assert_eq!(aws_resp.status(), 200); + assert!(aws_resp.text().await.unwrap().contains("aws-pong")); + aws_mock.assert_async().await; + + // Azure binding: gpt-4.1 on the Azure upstream with the bearer token. + let az_resp = client + .post(format!("{base}/azllm/v1/chat/completions")) + .json(&json!({"model":"gpt-4.1","messages":[{"role":"user","content":"hi"}]})) + .send() + .await + .expect("azure request"); + assert_eq!(az_resp.status(), 200); + assert!(az_resp.text().await.unwrap().contains("az-pong")); + azure_mock.assert_async().await; + + // Each binding lists its own cloud's curated catalog. + let aws_models: Value = client + .get(format!("{base}/llm/v1/models")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let aws_ids: Vec<&str> = aws_models["data"] + .as_array() + .unwrap() + .iter() + .map(|m| m["id"].as_str().unwrap()) + .collect(); + assert!(aws_ids.contains(&"gpt-oss-20b")); + assert!(aws_ids.contains(&"claude-opus-4.8")); + assert!(!aws_ids.contains(&"gpt-4.1"), "AWS catalog must not list the Azure model"); + + let az_models: Value = client + .get(format!("{base}/azllm/v1/models")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let az_ids: Vec<&str> = az_models["data"] + .as_array() + .unwrap() + .iter() + .map(|m| m["id"].as_str().unwrap()) + .collect(); + assert!(az_ids.contains(&"gpt-4.1")); + assert!(!az_ids.contains(&"gpt-oss-20b"), "Azure catalog must not list the AWS model"); +} diff --git a/crates/alien-gateway/tests/launcher.rs b/crates/alien-gateway/tests/launcher.rs new file mode 100644 index 000000000..e1e521e3a --- /dev/null +++ b/crates/alien-gateway/tests/launcher.rs @@ -0,0 +1,59 @@ +//! Integration tests for the `alien-ai-gateway` container launcher binary. +//! +//! These cover the passthrough paths (no cloud credentials needed). Credential +//! injection is exercised only by the end-to-end cloud tests; the lib's +//! `gateway_starts_and_serves_health` covers startup and health on empty bindings. + +use std::process::Command; + +// With no ALIEN_*_BINDING for an AI resource, the launcher must exec the app +// unchanged and must NOT set ALIEN_AI_GATEWAY_URL. +#[test] +fn passthrough_execs_app_without_gateway_when_no_ai_binding() { + let exe = env!("CARGO_BIN_EXE_alien-ai-gateway"); + // env_clear so a stray ALIEN_*_BINDING in the dev/CI env can't flip this into a + // gateway-spawn path. /bin/sh is an absolute path, so no PATH is needed. + let out = Command::new(exe) + .env_clear() + .args(["--", "/bin/sh", "-c", "printf '%s' \"gw=${ALIEN_AI_GATEWAY_URL:-unset}\""]) + .output() + .expect("launcher should run"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(String::from_utf8_lossy(&out.stdout), "gw=unset"); +} + +// A malformed invocation (no `--` separator, no command) fails fast, non-zero. +#[test] +fn missing_command_fails_fast() { + let exe = env!("CARGO_BIN_EXE_alien-ai-gateway"); + let out = Command::new(exe).output().expect("launcher should run"); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("alien-ai-gateway")); +} + +// A BYO-key (External) binding is not served by the gateway; the launcher must +// treat it as "no gateway" and exec the app directly (the SDK uses the key). +#[test] +fn external_binding_is_passthrough() { + let exe = env!("CARGO_BIN_EXE_alien-ai-gateway"); + // env_clear so only the External binding under test is present. + let out = Command::new(exe) + .env_clear() + .args(["--", "/bin/sh", "-c", "printf '%s' \"gw=${ALIEN_AI_GATEWAY_URL:-unset}\""]) + .env( + "ALIEN_LLM_BINDING", + r#"{"service":"external","provider":"openai","apiKey":"sk-x"}"#, + ) + .output() + .expect("launcher should run"); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(String::from_utf8_lossy(&out.stdout), "gw=unset"); +} diff --git a/crates/alien-gateway/tests/live_bedrock.rs b/crates/alien-gateway/tests/live_bedrock.rs new file mode 100644 index 000000000..1c7c1a93f --- /dev/null +++ b/crates/alien-gateway/tests/live_bedrock.rs @@ -0,0 +1,148 @@ +//! Live verification against real AWS Bedrock. Ignored by default — it makes a +//! real inference call and needs ambient AWS credentials in the environment. +//! +//! Run it with: +//! eval "$(aws configure export-credentials --profile

--format env)" +//! cargo test -p alien-gateway --test live_bedrock -- --ignored --nocapture +//! +//! This is the end-to-end proof that the Rust SigV4 signer produces a signature +//! Bedrock accepts: the gateway rewrites the public model id, signs with the SDK +//! default credential chain (service `bedrock`), and forwards to the real +//! `/openai/v1/chat/completions` endpoint — no static key, no mock. + +use std::net::Ipv4Addr; + +use alien_core::Platform; +use alien_gateway::{build_router, AmbientCred, AwsSigV4Cred, GatewayRoute}; +use serde_json::{json, Value}; + +async fn serve(router: axum::Router) -> String { + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind test server"); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + url +} + +#[tokio::test] +#[ignore = "hits real AWS Bedrock; needs ambient AWS credentials in the environment"] +async fn live_bedrock_openai_chat() { + let cred = AmbientCred::Aws( + AwsSigV4Cred::new("us-east-2") + .await + .expect("resolve AWS credentials from the default chain"), + ); + let route = GatewayRoute { + name: "llm".to_string(), + cloud: Platform::Aws, + region: Some("us-east-2".to_string()), + project: None, + azure_endpoint: None, + cred, + upstream_base_override: None, + }; + + let base = serve(build_router(vec![route])).await; + + let resp = reqwest::Client::new() + .post(format!("{base}/llm/v1/chat/completions")) + .json(&json!({ + "model": "gpt-oss-20b", + "messages": [{ "role": "user", "content": "Reply with exactly: pong" }], + "max_completion_tokens": 1024, + "reasoning_effort": "low" + })) + .send() + .await + .expect("request to the gateway"); + + let status = resp.status(); + let body: Value = resp.json().await.expect("gateway response should be JSON"); + eprintln!("live bedrock status={status} body={body}"); + + assert!( + status.is_success(), + "Bedrock must accept the Rust-signed request; got {status}: {body}" + ); + let content = body["choices"][0]["message"]["content"] + .as_str() + .unwrap_or_default(); + assert!( + content.to_lowercase().contains("pong"), + "expected a 'pong' completion, got: {content:?}" + ); +} + +#[tokio::test] +#[ignore = "hits real AWS Bedrock Claude via classic InvokeModel; needs a console model grant + ambient AWS credentials"] +async fn live_bedrock_claude_streaming() { + // The end-to-end proof for the Claude path: classic Bedrock InvokeModel with + // response streaming, whose event-stream frames the gateway decodes back into + // Anthropic SSE. Unlike the mock tests (which replay only the frames we chose), + // this exercises the decoder's frame-shape classification against Bedrock's REAL + // frame sequence — the one thing that reveals whether a benign non-chunk frame + // would be misclassified as an error and truncate a healthy stream. + let cred = AmbientCred::Aws( + AwsSigV4Cred::new("us-east-2") + .await + .expect("resolve AWS credentials from the default chain"), + ); + let route = GatewayRoute { + name: "llm".to_string(), + cloud: Platform::Aws, + region: Some("us-east-2".to_string()), + project: None, + azure_endpoint: None, + cred, + upstream_base_override: None, + }; + + let base = serve(build_router(vec![route])).await; + + let resp = reqwest::Client::new() + .post(format!("{base}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "stream": true, + "max_tokens": 64, + "messages": [{ "role": "user", "content": "Reply with exactly: pong" }] + })) + .send() + .await + .expect("request to the gateway"); + + let status = resp.status(); + let body = resp.text().await.expect("gateway response body"); + eprintln!("live claude stream status={status}\n{body}"); + + assert!( + status.is_success(), + "Claude via classic Bedrock must accept the request; got {status}: {body}" + ); + assert!( + body.contains("event: content_block_delta") || body.contains("\"text\""), + "expected decoded Anthropic SSE deltas: {body}" + ); + // A healthy stream must NOT trip the decoder's error branch: if it does, the + // payload-shape frame heuristic misclassified a real Bedrock frame. + assert!( + !body.contains("event: error"), + "a healthy Claude stream must not surface a decoder error: {body}" + ); + // The reply text streams as separate text_delta events ("p" then "ong"), so + // reconstruct it before asserting rather than expecting a contiguous substring. + let reply: String = body + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .filter_map(|data| serde_json::from_str::(data.trim()).ok()) + .filter(|event| event["type"] == "content_block_delta") + .filter_map(|event| event["delta"]["text"].as_str().map(str::to_owned)) + .collect(); + assert!( + reply.to_lowercase().contains("pong"), + "expected a 'pong' completion; reconstructed {reply:?} from stream: {body}" + ); +} diff --git a/crates/alien-gateway/tests/live_foundry_claude.rs b/crates/alien-gateway/tests/live_foundry_claude.rs new file mode 100644 index 000000000..b9f0c9e5c --- /dev/null +++ b/crates/alien-gateway/tests/live_foundry_claude.rs @@ -0,0 +1,112 @@ +//! Live verification of Claude on Azure Foundry. Ignored by default — it makes a +//! real inference call and needs a Foundry endpoint + Entra token in the +//! environment. +//! +//! Run it with: +//! export AZURE_AI_ENDPOINT="https://.cognitiveservices.azure.com/" +//! export AZURE_ACCESS_TOKEN="$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)" +//! cargo test -p alien-gateway --test live_foundry_claude -- --ignored --nocapture +//! +//! Besides proving the arm end-to-end, this settles the host/audience question the +//! code left open. AZURE_AI_ENDPOINT must be the account endpoint the AiBinding +//! carries in production (the AIServices account's `properties.endpoint`, the +//! `cognitiveservices.azure.com` shape) — a green run against the +//! `services.ai.azure.com` host would validate a host production bindings never +//! use. Probe order: (1) the binding-carried host with the `ai.azure.com` token +//! above; (2) on 404, the `services.ai.azure.com` host — meaning the arm needs a +//! host derivation; (3) on 401, retry the token with +//! `--resource https://cognitiveservices.azure.com` — meaning the audience swap +//! is unnecessary. Record which combination Foundry accepted. + +use std::net::Ipv4Addr; + +use alien_core::Platform; +use alien_gateway::{build_router, AmbientCred, BearerTokenCred, GatewayRoute}; +use serde_json::{json, Value}; + +async fn serve(router: axum::Router) -> String { + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind test server"); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + url +} + +fn foundry_route() -> GatewayRoute { + let endpoint = + std::env::var("AZURE_AI_ENDPOINT").expect("AZURE_AI_ENDPOINT must name the account"); + let token = + std::env::var("AZURE_ACCESS_TOKEN").expect("AZURE_ACCESS_TOKEN must hold an Entra token"); + GatewayRoute { + name: "llm".to_string(), + cloud: Platform::Azure, + region: None, + project: None, + azure_endpoint: Some(endpoint), + cred: AmbientCred::Bearer(BearerTokenCred::static_token(token)), + upstream_base_override: None, + } +} + +#[tokio::test] +#[ignore = "hits real Foundry Claude; needs AZURE_AI_ENDPOINT + AZURE_ACCESS_TOKEN and a Claude deployment on the resource"] +async fn live_foundry_claude_messages() { + let base = serve(build_router(vec![foundry_route()])).await; + + let resp = reqwest::Client::new() + .post(format!("{base}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "max_tokens": 64, + "messages": [{ "role": "user", "content": "Reply with exactly: pong" }] + })) + .send() + .await + .expect("request to the gateway"); + + let status = resp.status(); + let text = resp.text().await.expect("gateway response body"); + eprintln!("live foundry claude status={status} body={text}"); + let body: Value = serde_json::from_str(&text).expect("gateway response should be JSON"); + + assert!( + status.is_success(), + "Foundry must accept the Messages request; got {status}: {body}" + ); + let content = body["content"][0]["text"].as_str().unwrap_or_default(); + assert!( + content.to_lowercase().contains("pong"), + "expected a 'pong' reply, got: {content:?}" + ); +} + +#[tokio::test] +#[ignore = "hits real Foundry Claude streaming; needs AZURE_AI_ENDPOINT + AZURE_ACCESS_TOKEN and a Claude deployment on the resource"] +async fn live_foundry_claude_streaming() { + // Standard Anthropic streaming on the body's `stream` flag; the reply must be + // native Anthropic SSE passed through untouched. + let base = serve(build_router(vec![foundry_route()])).await; + + let resp = reqwest::Client::new() + .post(format!("{base}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "max_tokens": 64, + "stream": true, + "messages": [{ "role": "user", "content": "Reply with exactly: pong" }] + })) + .send() + .await + .expect("request to the gateway"); + + let status = resp.status(); + assert!(status.is_success(), "Foundry streaming must return 2xx; got {status}"); + let body = resp.text().await.expect("stream body"); + let head: String = body.chars().take(400).collect(); + eprintln!("live foundry claude stream head: {head}"); + assert!(body.contains("message_start"), "SSE must open with message_start: {body}"); + assert!(body.contains("message_stop"), "SSE must close with message_stop: {body}"); +} diff --git a/crates/alien-gateway/tests/live_vertex_claude.rs b/crates/alien-gateway/tests/live_vertex_claude.rs new file mode 100644 index 000000000..6b3722d68 --- /dev/null +++ b/crates/alien-gateway/tests/live_vertex_claude.rs @@ -0,0 +1,104 @@ +//! Live verification of Claude on GCP Vertex. Ignored by default — it makes a +//! real inference call and needs a project + access token in the environment. +//! +//! Run it with: +//! export GCP_PROJECT= +//! export GCP_ACCESS_TOKEN="$(gcloud auth print-access-token)" +//! cargo test -p alien-gateway --test live_vertex_claude -- --ignored --nocapture +//! +//! This is the end-to-end proof for the Vertex Claude arm: the gateway moves the +//! model id into the `:rawPredict` URL, injects the Vertex version marker, and the +//! project's Model Garden entitlement accepts the call — the one thing code +//! reading cannot establish. + +use std::net::Ipv4Addr; + +use alien_core::Platform; +use alien_gateway::{build_router, AmbientCred, BearerTokenCred, GatewayRoute}; +use serde_json::{json, Value}; + +async fn serve(router: axum::Router) -> String { + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind test server"); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + url +} + +fn vertex_route() -> GatewayRoute { + let project = std::env::var("GCP_PROJECT").expect("GCP_PROJECT must name the target project"); + let token = + std::env::var("GCP_ACCESS_TOKEN").expect("GCP_ACCESS_TOKEN must hold a bearer token"); + GatewayRoute { + name: "llm".to_string(), + cloud: Platform::Gcp, + region: Some("global".to_string()), + project: Some(project), + azure_endpoint: None, + cred: AmbientCred::Bearer(BearerTokenCred::static_token(token)), + upstream_base_override: None, + } +} + +#[tokio::test] +#[ignore = "hits real Vertex Claude; needs GCP_PROJECT + GCP_ACCESS_TOKEN and a Model Garden grant"] +async fn live_vertex_claude_messages() { + let base = serve(build_router(vec![vertex_route()])).await; + + let resp = reqwest::Client::new() + .post(format!("{base}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "max_tokens": 64, + "messages": [{ "role": "user", "content": "Reply with exactly: pong" }] + })) + .send() + .await + .expect("request to the gateway"); + + let status = resp.status(); + let text = resp.text().await.expect("gateway response body"); + eprintln!("live vertex claude status={status} body={text}"); + let body: Value = serde_json::from_str(&text).expect("gateway response should be JSON"); + + assert!( + status.is_success(), + "Vertex must accept the rawPredict request; got {status}: {body}" + ); + let content = body["content"][0]["text"].as_str().unwrap_or_default(); + assert!( + content.to_lowercase().contains("pong"), + "expected a 'pong' reply, got: {content:?}" + ); +} + +#[tokio::test] +#[ignore = "hits real Vertex Claude streaming; needs GCP_PROJECT + GCP_ACCESS_TOKEN and a Model Garden grant"] +async fn live_vertex_claude_streaming() { + // Streaming picks the `:streamRawPredict` verb and must come back as native + // Anthropic SSE — message_start through message_stop, passed through untouched. + let base = serve(build_router(vec![vertex_route()])).await; + + let resp = reqwest::Client::new() + .post(format!("{base}/llm/v1/messages")) + .json(&json!({ + "model": "claude-haiku-4.5", + "max_tokens": 64, + "stream": true, + "messages": [{ "role": "user", "content": "Reply with exactly: pong" }] + })) + .send() + .await + .expect("request to the gateway"); + + let status = resp.status(); + assert!(status.is_success(), "Vertex streaming must return 2xx; got {status}"); + let body = resp.text().await.expect("stream body"); + let head: String = body.chars().take(400).collect(); + eprintln!("live vertex claude stream head: {head}"); + assert!(body.contains("message_start"), "SSE must open with message_start: {body}"); + assert!(body.contains("message_stop"), "SSE must close with message_stop: {body}"); +} diff --git a/crates/alien-infra/src/ai/aws.rs b/crates/alien-infra/src/ai/aws.rs new file mode 100644 index 000000000..d3953c830 --- /dev/null +++ b/crates/alien-infra/src/ai/aws.rs @@ -0,0 +1,189 @@ +use std::time::Duration; + +use tracing::info; + +use crate::core::{ResourceControllerContext, ResourcePermissionsHelper}; +use crate::error::{ErrorData, Result}; +use alien_core::{ + bindings::AiBinding, Ai, AiHeartbeatData, AiHeartbeatStatus, AiOutputs, + AwsBedrockAiHeartbeatData, HeartbeatBackend, Platform, ResourceHeartbeat, + ResourceHeartbeatData, ResourceOutputs, ResourceStatus, +}; +use alien_error::{AlienError, Context, IntoAlienError}; +use alien_macros::controller; +use chrono::Utc; + +#[controller] +pub struct AwsAiController { + /// AWS region where Bedrock is accessed. None until create_start runs. + pub(crate) region: Option, +} + +#[controller] +impl AwsAiController { + // ─────────────── CREATE FLOW ────────────────────────────── + + #[flow_entry(Create)] + #[handler( + state = CreateStart, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn create_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + let aws_config = ctx.get_aws_config()?; + self.region = Some(aws_config.region.clone()); + + info!(id=%config.id, region=%aws_config.region, "AWS AI (Bedrock) controller: no resource to create, applying permissions"); + + Ok(HandlerAction::Continue { + state: ApplyingResourcePermissions, + suggested_delay: None, + }) + } + + #[handler( + state = ApplyingResourcePermissions, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn applying_resource_permissions( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let config = ctx.desired_resource_config::()?; + + info!(ai=%config.id, "Applying resource-scoped permissions for Bedrock AI gateway"); + + // Bedrock invoke grants use foundation-model/* ARNs (not per-resource ARNs). + // config.id is passed as resource_name; the ai/invoke permission set binding + // uses `arn:aws:bedrock:*::foundation-model/*` which is region/account-wide. + ResourcePermissionsHelper::apply_aws_resource_scoped_permissions( + ctx, &config.id, &config.id, "ai", + ) + .await?; + + info!(ai=%config.id, "Successfully applied resource-scoped permissions"); + + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }) + } + + // ─────────────── READY STATE ──────────────────────────────── + // Loops as a heartbeat tick; Bedrock has no per-stack resource to poll. + + #[handler( + state = Ready, + on_failure = RefreshFailed, + status = ResourceStatus::Running, + )] + async fn ready(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + let region = self.region.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + resource_id: Some(config.id.clone()), + message: "Region not set in state".to_string(), + }) + })?; + info!(id=%config.id, "AWS AI heartbeat tick"); + ctx.emit_heartbeat(ResourceHeartbeat { + deployment_id: None, + resource_id: config.id.clone(), + resource_type: Ai::RESOURCE_TYPE, + controller_platform: Platform::Aws, + backend: HeartbeatBackend::Aws, + observed_at: Utc::now(), + data: ResourceHeartbeatData::Ai(AiHeartbeatData::AwsBedrock( + AwsBedrockAiHeartbeatData { + status: AiHeartbeatStatus::default(), + region: region.clone(), + }, + )), + raw: vec![], + }); + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: Some(Duration::from_secs(30)), + }) + } + + // ─────────────── UPDATE FLOW ────────────────────────────── + // Ai has no mutable fields -- update is a no-op that also recovers RefreshFailed. + + #[flow_entry(Update, from = [Ready, RefreshFailed])] + #[handler( + state = UpdateStart, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + info!(id=%config.id, "AWS AI update (no-op -- no mutable fields)"); + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }) + } + + // ─────────────── DELETE FLOW ────────────────────────────── + // AWS AI creates no cloud resource; deletion is always a no-op. + + #[flow_entry(Delete)] + #[handler( + state = DeleteStart, + on_failure = DeleteFailed, + status = ResourceStatus::Deleting, + )] + async fn delete_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + info!(id=%config.id, "AWS AI delete (no-op -- Bedrock has no per-stack resource to remove)"); + Ok(HandlerAction::Continue { + state: Deleted, + suggested_delay: None, + }) + } + + // ─────────────── TERMINALS ──────────────────────────────── + + terminal_state!( + state = CreateFailed, + status = ResourceStatus::ProvisionFailed + ); + + terminal_state!(state = UpdateFailed, status = ResourceStatus::UpdateFailed); + + terminal_state!(state = DeleteFailed, status = ResourceStatus::DeleteFailed); + + terminal_state!( + state = RefreshFailed, + status = ResourceStatus::RefreshFailed + ); + + terminal_state!(state = Deleted, status = ResourceStatus::Deleted); + + fn build_outputs(&self) -> Option { + let region = self.region.as_ref()?; + Some(ResourceOutputs::new(AiOutputs { + provider: "bedrock".into(), + endpoint: Some(format!("https://bedrock-mantle.{}.api.aws/v1", region)), + account: None, + })) + } + + fn get_binding_params(&self) -> Result> { + let region = match &self.region { + Some(r) => r, + None => return Ok(None), + }; + Ok(Some( + serde_json::to_value(AiBinding::bedrock(region)) + .into_alien_error() + .context(ErrorData::ResourceStateSerializationFailed { + resource_id: "binding".to_string(), + message: "Failed to serialize AI binding parameters".to_string(), + })?, + )) + } +} diff --git a/crates/alien-infra/src/ai/aws_import.rs b/crates/alien-infra/src/ai/aws_import.rs new file mode 100644 index 000000000..ccc8e2269 --- /dev/null +++ b/crates/alien-infra/src/ai/aws_import.rs @@ -0,0 +1,31 @@ +//! Importer for AWS AI (Bedrock). +//! +//! AWS AI creates no cloud resource; the importer carries the region from the +//! import payload directly into the controller state. + +use alien_core::{ + import::{data::AwsAiImportData, ImportContext}, + Result, StackResourceState, +}; + +use crate::import::ResourceImporter; +use crate::import_helpers::make_imported_state; +use crate::ai::aws::AwsAiState; +use crate::ai::AwsAiController; + +/// AWS Bedrock AI importer. +#[derive(Debug, Default)] +pub struct AwsAiImporter; + +impl ResourceImporter for AwsAiImporter { + type ImportData = AwsAiImportData; + + fn import(&self, data: AwsAiImportData, ctx: &ImportContext<'_>) -> Result { + let controller = AwsAiController { + state: AwsAiState::Ready, + region: Some(data.region), + _internal_stay_count: None, + }; + make_imported_state(controller, ctx) + } +} diff --git a/crates/alien-infra/src/ai/azure.rs b/crates/alien-infra/src/ai/azure.rs new file mode 100644 index 000000000..2859cb79b --- /dev/null +++ b/crates/alien-infra/src/ai/azure.rs @@ -0,0 +1,998 @@ +use tracing::info; + +use crate::azure_utils::get_resource_group_name; +use crate::core::{ResourceControllerContext, ResourcePermissionsHelper}; +use crate::error::{ErrorData, Result}; +use alien_azure_clients::azure::cognitive_services::{ + CognitiveServicesAccountCreateParameters, CognitiveServicesAccountCreateProperties, + CognitiveServicesDeploymentCreateParameters, CognitiveServicesDeploymentCreateProperties, + CognitiveServicesDeploymentModel, CognitiveServicesDeploymentSku, CognitiveServicesSku, +}; +use alien_azure_clients::long_running_operation::OperationResult; +use alien_client_core::ErrorData as CloudClientErrorData; +use alien_core::{ + bindings::AiBinding, Ai, AiHeartbeatData, AiHeartbeatStatus, AiOutputs, + AzureFoundryAiHeartbeatData, HeartbeatBackend, Platform, ResourceHeartbeat, + ResourceHeartbeatData, ResourceOutputs, ResourceStatus, +}; +use alien_error::{AlienError, Context, ContextError, IntoAlienError}; +use alien_macros::controller; +use chrono::Utc; + +/// Provisioned throughput (in GlobalStandard units) for each predefined model +/// deployment. A conservative default; per-region quota tuning is a deploy-time +/// concern verified against the target subscription. +const DEFAULT_DEPLOYMENT_CAPACITY: i32 = 1; + +/// Derives a globally-unique AIServices account name from the stack prefix and +/// resource id. Azure account names must be 2-64 chars, alphanumeric + hyphens, +/// start with a letter, and end with a letter or digit. +fn make_account_name(prefix: &str, id: &str) -> String { + let raw = format!("{}-{}", prefix, id); + // Keep only alphanumeric chars and hyphens; collapse leading/trailing hyphens. + let cleaned: String = raw + .chars() + .map(|c| if c.is_alphanumeric() || c == '-' { c } else { '-' }) + .collect(); + let trimmed = cleaned.trim_matches('-').to_string(); + if trimmed.len() > 64 { + trimmed[..64].trim_end_matches('-').to_string() + } else { + trimmed + } +} + +#[controller] +pub struct AzureAiController { + /// The name of the Azure AIServices account. + pub(crate) account_name: Option, + /// The endpoint URL returned by the AIServices account once provisioned. + pub(crate) endpoint: Option, + /// The Azure resource group containing the account. + pub(crate) resource_group: Option, + /// The Azure region where the account is created. + pub(crate) location: Option, +} + +#[controller] +impl AzureAiController { + // ─────────────── CREATE FLOW ────────────────────────────── + + #[flow_entry(Create)] + #[handler( + state = CreateStart, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn create_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let azure_config = ctx.get_azure_config()?; + let config = ctx.desired_resource_config::()?; + + let account_name = make_account_name(&ctx.resource_prefix, &config.id); + let resource_group_name = get_resource_group_name(&ctx.state)?; + let location = azure_config.region.clone().ok_or_else(|| { + AlienError::new(ErrorData::ClientConfigInvalid { + platform: Platform::Azure, + message: "Azure region is required but not specified in configuration".to_string(), + }) + })?; + + info!( + id = %config.id, + account_name = %account_name, + resource_group = %resource_group_name, + location = %location, + "Creating Azure AIServices account" + ); + + let cognitive_client = ctx + .service_provider + .get_azure_cognitive_services_client(azure_config)?; + + let parameters = CognitiveServicesAccountCreateParameters { + location: location.clone(), + kind: "AIServices".to_string(), + sku: CognitiveServicesSku { + name: "S0".to_string(), + }, + properties: CognitiveServicesAccountCreateProperties { + custom_sub_domain_name: account_name.clone(), + }, + }; + + let operation_result = cognitive_client + .create_account(&resource_group_name, &account_name, ¶meters) + .await + .context(ErrorData::CloudPlatformError { + message: format!("Failed to create Azure AIServices account '{}'", account_name), + resource_id: Some(config.id.clone()), + })?; + + // Store these before matching so both branches can reference them. + self.account_name = Some(account_name.clone()); + self.resource_group = Some(resource_group_name.clone()); + self.location = Some(location); + + match operation_result { + OperationResult::Completed(account) => { + self.endpoint = account + .properties + .and_then(|p| p.endpoint); + + info!(account_name = %account_name, "Azure AIServices account created (synchronous)"); + + Ok(HandlerAction::Continue { + state: WaitingForAccountCreation, + suggested_delay: None, + }) + } + OperationResult::LongRunning(_) => { + info!(account_name = %account_name, "Azure AIServices account creation is in progress"); + + Ok(HandlerAction::Continue { + state: WaitingForAccountCreation, + suggested_delay: Some(std::time::Duration::from_secs(10)), + }) + } + } + } + + #[handler( + state = WaitingForAccountCreation, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn waiting_for_account_creation( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let azure_config = ctx.get_azure_config()?; + let config = ctx.desired_resource_config::()?; + + let account_name = self.account_name.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Account name not set in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + let resource_group_name = self.resource_group.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Resource group not set in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + + info!(account_name = %account_name, "Polling Azure AIServices account provisioning state"); + + let cognitive_client = ctx + .service_provider + .get_azure_cognitive_services_client(azure_config)?; + + match cognitive_client + .get_account(resource_group_name, account_name) + .await + { + Ok(account) => { + let provisioning_state = account + .properties + .as_ref() + .and_then(|p| p.provisioning_state.as_deref()) + .unwrap_or(""); + + if provisioning_state.eq_ignore_ascii_case("Succeeded") { + self.endpoint = account.properties.and_then(|p| p.endpoint); + + info!( + account_name = %account_name, + endpoint = ?self.endpoint, + "Azure AIServices account provisioned successfully" + ); + + Ok(HandlerAction::Continue { + state: ApplyingResourcePermissions, + suggested_delay: None, + }) + } else { + info!( + account_name = %account_name, + provisioning_state = %provisioning_state, + "Azure AIServices account not yet ready, retrying" + ); + + Ok(HandlerAction::Continue { + state: WaitingForAccountCreation, + suggested_delay: Some(std::time::Duration::from_secs(10)), + }) + } + } + Err(e) + if matches!( + &e.error, + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) + ) => + { + info!(account_name = %account_name, "Azure AIServices account not yet visible, retrying"); + Ok(HandlerAction::Continue { + state: WaitingForAccountCreation, + suggested_delay: Some(std::time::Duration::from_secs(10)), + }) + } + Err(e) => Err(e.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to poll Azure AIServices account '{}' provisioning state", + account_name + ), + resource_id: Some(config.id.clone()), + })), + } + } + + #[handler( + state = ApplyingResourcePermissions, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn applying_resource_permissions( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let config = ctx.desired_resource_config::()?; + + info!(id = %config.id, "Applying Cognitive Services OpenAI User role on AIServices account"); + + let account_name = self.account_name.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Account name not set in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + let resource_group_name = self.resource_group.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Resource group not set in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + + use alien_azure_clients::authorization::Scope; + + let resource_scope = Scope::Resource { + resource_group_name: resource_group_name.clone(), + resource_provider: "Microsoft.CognitiveServices".to_string(), + parent_resource_path: None, + resource_type: "accounts".to_string(), + resource_name: account_name.clone(), + }; + + ResourcePermissionsHelper::apply_azure_resource_scoped_permissions( + ctx, + &config.id, + account_name, + resource_scope, + "AI", + "ai", + ) + .await?; + + info!(id = %config.id, "Successfully applied Cognitive Services OpenAI User role"); + + Ok(HandlerAction::Continue { + state: DeployingModels, + suggested_delay: None, + }) + } + + // ─────────────── MODEL DEPLOYMENT ─────────────────────────── + + #[handler( + state = DeployingModels, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn deploying_models( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let azure_config = ctx.get_azure_config()?; + let config = ctx.desired_resource_config::()?; + + let account_name = self.account_name.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Account name not set in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + let resource_group_name = self.resource_group.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Resource group not set in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + + let cognitive_client = ctx + .service_provider + .get_azure_cognitive_services_client(azure_config)?; + + // PUT is idempotent, so re-entering this state (e.g. after a retry) re-issues + // the same deployments harmlessly. + for (deployment_name, model_name, model_version) in alien_core::ai_catalog::azure_deployments() + { + info!( + account_name = %account_name, + deployment = %deployment_name, + "Creating Azure model deployment" + ); + + let parameters = CognitiveServicesDeploymentCreateParameters { + sku: CognitiveServicesDeploymentSku { + name: "GlobalStandard".to_string(), + capacity: DEFAULT_DEPLOYMENT_CAPACITY, + }, + properties: CognitiveServicesDeploymentCreateProperties { + model: CognitiveServicesDeploymentModel { + format: "OpenAI".to_string(), + name: model_name.to_string(), + version: model_version.to_string(), + }, + }, + }; + + cognitive_client + .create_deployment( + resource_group_name, + account_name, + deployment_name, + ¶meters, + ) + .await + .context(ErrorData::CloudPlatformError { + message: format!( + "Failed to create Azure model deployment '{}'", + deployment_name + ), + resource_id: Some(config.id.clone()), + })?; + } + + Ok(HandlerAction::Continue { + state: WaitingForDeployments, + suggested_delay: Some(std::time::Duration::from_secs(10)), + }) + } + + #[handler( + state = WaitingForDeployments, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn waiting_for_deployments( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let azure_config = ctx.get_azure_config()?; + let config = ctx.desired_resource_config::()?; + + let account_name = self.account_name.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Account name not set in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + let resource_group_name = self.resource_group.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Resource group not set in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + + let cognitive_client = ctx + .service_provider + .get_azure_cognitive_services_client(azure_config)?; + + for (deployment_name, _, _) in alien_core::ai_catalog::azure_deployments() { + match cognitive_client + .get_deployment(resource_group_name, account_name, deployment_name) + .await + { + Ok(deployment) => { + let provisioning_state = deployment + .properties + .as_ref() + .and_then(|p| p.provisioning_state.as_deref()) + .unwrap_or(""); + + // Deployments routinely reach a terminal failure (in-region + // capacity/quota, or the model version not offered there). Fail + // fast to CreateFailed rather than polling a dead deployment forever. + if provisioning_state.eq_ignore_ascii_case("Failed") + || provisioning_state.eq_ignore_ascii_case("Canceled") + { + return Err(AlienError::new(ErrorData::CloudPlatformError { + message: format!( + "Azure model deployment '{}' entered terminal state '{}'", + deployment_name, provisioning_state + ), + resource_id: Some(config.id.clone()), + })); + } + + if !provisioning_state.eq_ignore_ascii_case("Succeeded") { + info!( + account_name = %account_name, + deployment = %deployment_name, + provisioning_state = %provisioning_state, + "Model deployment not yet ready, retrying" + ); + return Ok(HandlerAction::Continue { + state: WaitingForDeployments, + suggested_delay: Some(std::time::Duration::from_secs(10)), + }); + } + } + Err(e) + if matches!( + &e.error, + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) + ) => + { + info!( + account_name = %account_name, + deployment = %deployment_name, + "Model deployment not yet visible, retrying" + ); + return Ok(HandlerAction::Continue { + state: WaitingForDeployments, + suggested_delay: Some(std::time::Duration::from_secs(10)), + }); + } + Err(e) => { + return Err(e.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to poll Azure model deployment '{}'", + deployment_name + ), + resource_id: Some(config.id.clone()), + })) + } + } + } + + info!(account_name = %account_name, "All Azure model deployments provisioned"); + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }) + } + + // ─────────────── READY STATE ──────────────────────────────── + + #[handler( + state = Ready, + on_failure = RefreshFailed, + status = ResourceStatus::Running, + )] + async fn ready(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + + let account_name = self.account_name.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + resource_id: Some(config.id.clone()), + message: "Account name not set in state".to_string(), + }) + })?; + let _endpoint = self.endpoint.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + resource_id: Some(config.id.clone()), + message: "Endpoint not set in state".to_string(), + }) + })?; + + info!(id = %config.id, "Azure AI heartbeat tick"); + + ctx.emit_heartbeat(ResourceHeartbeat { + deployment_id: None, + resource_id: config.id.clone(), + resource_type: Ai::RESOURCE_TYPE, + controller_platform: Platform::Azure, + backend: HeartbeatBackend::Azure, + observed_at: Utc::now(), + data: ResourceHeartbeatData::Ai(AiHeartbeatData::AzureFoundry( + AzureFoundryAiHeartbeatData { + status: AiHeartbeatStatus::default(), + account_name: account_name.clone(), + endpoint: self.endpoint.clone(), + resource_group: self.resource_group.clone(), + location: self.location.clone(), + }, + )), + raw: vec![], + }); + + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: Some(std::time::Duration::from_secs(30)), + }) + } + + // ─────────────── UPDATE FLOW ────────────────────────────── + + #[flow_entry(Update, from = [Ready, RefreshFailed])] + #[handler( + state = UpdateStart, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + info!(id = %config.id, "Azure AI update (no-op -- no mutable fields)"); + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }) + } + + // ─────────────── DELETE FLOW ────────────────────────────── + + #[flow_entry(Delete)] + #[handler( + state = DeleteStart, + on_failure = DeleteFailed, + status = ResourceStatus::Deleting, + )] + async fn delete_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let azure_config = ctx.get_azure_config()?; + let config = ctx.desired_resource_config::()?; + + let account_name = match &self.account_name { + Some(name) => name.clone(), + None => { + info!(id = %config.id, "No Azure AIServices account to delete"); + return Ok(HandlerAction::Continue { + state: Deleted, + suggested_delay: None, + }); + } + }; + let resource_group_name = self.resource_group.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Resource group not set in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + + info!( + id = %config.id, + account_name = %account_name, + "Deleting Azure AIServices account" + ); + + let cognitive_client = ctx + .service_provider + .get_azure_cognitive_services_client(azure_config)?; + + match cognitive_client + .delete_account(resource_group_name, &account_name) + .await + { + Ok(()) => { + info!(account_name = %account_name, "Azure AIServices account deleted, polling for removal"); + Ok(HandlerAction::Continue { + state: WaitingForAccountDeletion, + suggested_delay: Some(std::time::Duration::from_secs(5)), + }) + } + Err(e) + if matches!( + e.error, + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) + ) => + { + info!(account_name = %account_name, "Azure AIServices account already deleted"); + self.clear_state(); + Ok(HandlerAction::Continue { + state: Deleted, + suggested_delay: None, + }) + } + Err(e) => Err(e.context(ErrorData::CloudPlatformError { + message: format!("Failed to delete Azure AIServices account '{}'", account_name), + resource_id: Some(config.id.clone()), + })), + } + } + + #[handler( + state = WaitingForAccountDeletion, + on_failure = DeleteFailed, + status = ResourceStatus::Deleting, + )] + async fn waiting_for_account_deletion( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let azure_config = ctx.get_azure_config()?; + let config = ctx.desired_resource_config::()?; + + let account_name = self.account_name.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Account name not set in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + let resource_group_name = self.resource_group.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + message: "Resource group not set in state".to_string(), + resource_id: Some(config.id.clone()), + }) + })?; + + info!(account_name = %account_name, "Polling Azure AIServices account deletion"); + + let cognitive_client = ctx + .service_provider + .get_azure_cognitive_services_client(azure_config)?; + + match cognitive_client + .get_account(resource_group_name, account_name) + .await + { + Err(e) + if matches!( + &e.error, + Some(CloudClientErrorData::RemoteResourceNotFound { .. }) + ) => + { + info!(account_name = %account_name, "Azure AIServices account confirmed deleted"); + self.clear_state(); + Ok(HandlerAction::Continue { + state: Deleted, + suggested_delay: None, + }) + } + Ok(_) => { + info!( + account_name = %account_name, + "Azure AIServices account still exists, retrying deletion poll" + ); + Ok(HandlerAction::Continue { + state: WaitingForAccountDeletion, + suggested_delay: Some(std::time::Duration::from_secs(10)), + }) + } + Err(e) => Err(e.context(ErrorData::CloudPlatformError { + message: format!( + "Failed to poll Azure AIServices account '{}' deletion state", + account_name + ), + resource_id: Some(config.id.clone()), + })), + } + } + + // ─────────────── TERMINALS ──────────────────────────────── + + terminal_state!( + state = CreateFailed, + status = ResourceStatus::ProvisionFailed + ); + + terminal_state!(state = UpdateFailed, status = ResourceStatus::UpdateFailed); + + terminal_state!(state = DeleteFailed, status = ResourceStatus::DeleteFailed); + + terminal_state!( + state = RefreshFailed, + status = ResourceStatus::RefreshFailed + ); + + terminal_state!(state = Deleted, status = ResourceStatus::Deleted); + + fn build_outputs(&self) -> Option { + let endpoint = self.endpoint.clone()?; + let account_name = self.account_name.clone()?; + Some(ResourceOutputs::new(AiOutputs { + provider: "foundry".into(), + endpoint: Some(endpoint), + account: Some(account_name), + })) + } + + fn get_binding_params(&self) -> Result> { + let endpoint = match &self.endpoint { + Some(ep) => ep.clone(), + None => return Ok(None), + }; + let account_name = match &self.account_name { + Some(a) => a.clone(), + None => return Ok(None), + }; + Ok(Some( + serde_json::to_value(AiBinding::foundry(endpoint, account_name)) + .into_alien_error() + .context(ErrorData::ResourceStateSerializationFailed { + resource_id: "binding".to_string(), + message: "Failed to serialize Azure AI binding parameters".to_string(), + })?, + )) + } +} + +impl AzureAiController { + fn clear_state(&mut self) { + self.account_name = None; + self.endpoint = None; + self.resource_group = None; + self.location = None; + } + + /// Creates a controller in the ready state for testing purposes. + #[cfg(feature = "test-utils")] + pub fn mock_ready(account_name: &str, endpoint: &str) -> Self { + Self { + state: AzureAiState::Ready, + account_name: Some(account_name.to_string()), + endpoint: Some(endpoint.to_string()), + resource_group: Some("mock-rg".to_string()), + location: Some("eastus".to_string()), + _internal_stay_count: None, + } + } + + /// Creates a controller poised to deploy the predefined model set (account + /// already provisioned), for testing the model-deployment states. + #[cfg(feature = "test-utils")] + pub fn mock_deploying(account_name: &str, endpoint: &str) -> Self { + Self { + state: AzureAiState::DeployingModels, + account_name: Some(account_name.to_string()), + endpoint: Some(endpoint.to_string()), + resource_group: Some("mock-rg".to_string()), + location: Some("eastus".to_string()), + _internal_stay_count: None, + } + } +} + +#[cfg(all(test, feature = "test-utils"))] +mod tests { + use super::*; + use crate::core::controller_test::SingleControllerExecutor; + use crate::MockPlatformServiceProvider; + use alien_azure_clients::azure::cognitive_services::{ + CognitiveServicesDeployment, CognitiveServicesDeploymentProperties, + MockCognitiveServicesAccountsApi, + }; + use alien_azure_clients::long_running_operation::OperationResult; + use alien_client_core::ErrorData as CloudClientErrorData; + use alien_core::{Ai, AiOutputs, Platform, ResourceStatus}; + use alien_error::AlienError; + use std::sync::Arc; + + fn basic_ai() -> Ai { + Ai::new("my-ai".to_string()).build() + } + + /// A deployment whose model + provisioning state are both reported succeeded. + fn succeeded_deployment() -> CognitiveServicesDeployment { + CognitiveServicesDeployment { + sku: None, + properties: Some(CognitiveServicesDeploymentProperties { + model: CognitiveServicesDeploymentModel { + format: "OpenAI".to_string(), + name: "gpt-4.1".to_string(), + version: "2025-04-14".to_string(), + }, + provisioning_state: Some("Succeeded".to_string()), + }), + } + } + + fn setup_mock_provider_for_deletion(expect_not_found: bool) -> Arc { + let mut mock_provider = MockPlatformServiceProvider::new(); + + mock_provider + .expect_get_azure_cognitive_services_client() + .returning(move |_| { + let mut mock_cognitive = MockCognitiveServicesAccountsApi::new(); + + if expect_not_found { + // Simulate account already deleted. + mock_cognitive + .expect_delete_account() + .returning(|_, _| { + Err(AlienError::new(CloudClientErrorData::RemoteResourceNotFound { + resource_type: "CognitiveServicesAccount".to_string(), + resource_name: "my-ai".to_string(), + })) + }); + } else { + // Successful delete then polling confirms deletion. + mock_cognitive + .expect_delete_account() + .returning(|_, _| Ok(())); + mock_cognitive + .expect_get_account() + .returning(|_, _| { + Err(AlienError::new(CloudClientErrorData::RemoteResourceNotFound { + resource_type: "CognitiveServicesAccount".to_string(), + resource_name: "my-ai".to_string(), + })) + }); + } + + Ok(Arc::new(mock_cognitive)) + }); + + Arc::new(mock_provider) + } + + #[tokio::test] + async fn test_deploys_predefined_models_then_ready() { + // Account already provisioned; drive DeployingModels -> WaitingForDeployments + // -> Ready with create_deployment + get_deployment(Succeeded) mocked. + let mut mock_provider = MockPlatformServiceProvider::new(); + mock_provider + .expect_get_azure_cognitive_services_client() + .returning(move |_| { + let mut mock_cognitive = MockCognitiveServicesAccountsApi::new(); + mock_cognitive + .expect_create_deployment() + .returning(|_, _, _, _| Ok(OperationResult::Completed(succeeded_deployment()))); + mock_cognitive + .expect_get_deployment() + .returning(|_, _, _| Ok(succeeded_deployment())); + Ok(Arc::new(mock_cognitive)) + }); + + let mut executor = SingleControllerExecutor::builder() + .resource(basic_ai()) + .controller(AzureAiController::mock_deploying( + "my-ai-account", + "https://my-ai.cognitiveservices.azure.com/", + )) + .platform(Platform::Azure) + .service_provider(Arc::new(mock_provider)) + .build() + .await + .unwrap(); + + // Step through the deployment states; Ready is not terminal, so stop once + // the controller reports Running. + for _ in 0..5 { + if executor.status() == ResourceStatus::Running { + break; + } + executor.step().await.unwrap(); + } + + assert_eq!( + executor.status(), + ResourceStatus::Running, + "controller should reach Ready after deploying the predefined models" + ); + } + + #[tokio::test] + async fn test_deployment_terminal_failure_fails_fast() { + // A deployment that reports a terminal "Failed" state must route to + // CreateFailed (ProvisionFailed), not poll forever. + let mut mock_provider = MockPlatformServiceProvider::new(); + mock_provider + .expect_get_azure_cognitive_services_client() + .returning(move |_| { + let mut mock_cognitive = MockCognitiveServicesAccountsApi::new(); + mock_cognitive + .expect_create_deployment() + .returning(|_, _, _, _| Ok(OperationResult::Completed(succeeded_deployment()))); + mock_cognitive.expect_get_deployment().returning(|_, _, _| { + Ok(CognitiveServicesDeployment { + sku: None, + properties: Some(CognitiveServicesDeploymentProperties { + model: CognitiveServicesDeploymentModel { + format: "OpenAI".to_string(), + name: "gpt-4.1".to_string(), + version: "2025-04-14".to_string(), + }, + provisioning_state: Some("Failed".to_string()), + }), + }) + }); + Ok(Arc::new(mock_cognitive)) + }); + + let mut executor = SingleControllerExecutor::builder() + .resource(basic_ai()) + .controller(AzureAiController::mock_deploying( + "my-ai-account", + "https://my-ai.cognitiveservices.azure.com/", + )) + .platform(Platform::Azure) + .service_provider(Arc::new(mock_provider)) + .build() + .await + .unwrap(); + + // A "Failed" deployment must surface as a handler error (which the executor + // routes to CreateFailed), not an endless poll. Bounded so a regression + // (poll-forever) fails the test instead of hanging. + let mut surfaced_error = false; + for _ in 0..5 { + if executor.step().await.is_err() { + surfaced_error = true; + break; + } + } + + assert!( + surfaced_error, + "a terminal deployment failure must surface as an error, not a silent retry" + ); + } + + #[tokio::test] + async fn test_ready_controller_has_correct_outputs() { + let account_name = "my-ai-account"; + let endpoint = "https://my-ai.cognitiveservices.azure.com/"; + + let mock_provider = setup_mock_provider_for_deletion(false); + let executor = SingleControllerExecutor::builder() + .resource(basic_ai()) + .controller(AzureAiController::mock_ready(account_name, endpoint)) + .platform(Platform::Azure) + .service_provider(mock_provider) + .build() + .await + .unwrap(); + + assert_eq!(executor.status(), ResourceStatus::Running); + + let outputs = executor.outputs().unwrap(); + let ai_outputs = outputs.downcast_ref::().unwrap(); + assert_eq!(ai_outputs.provider, "foundry"); + assert_eq!(ai_outputs.endpoint.as_deref(), Some(endpoint)); + assert_eq!(ai_outputs.account.as_deref(), Some(account_name)); + } + + #[tokio::test] + async fn test_delete_flow_succeeds() { + let account_name = "my-ai-account"; + let endpoint = "https://my-ai.cognitiveservices.azure.com/"; + + let mock_provider = setup_mock_provider_for_deletion(false); + let mut executor = SingleControllerExecutor::builder() + .resource(basic_ai()) + .controller(AzureAiController::mock_ready(account_name, endpoint)) + .platform(Platform::Azure) + .service_provider(mock_provider) + .build() + .await + .unwrap(); + + executor.delete().unwrap(); + executor.run_until_terminal().await.unwrap(); + + assert_eq!(executor.status(), ResourceStatus::Deleted); + } + + #[tokio::test] + async fn test_delete_already_gone_succeeds() { + // Deleting when the account is already gone (RemoteResourceNotFound on delete_account) + // must succeed, not fail. This is the best-effort-delete path. + let account_name = "my-ai-account"; + let endpoint = "https://my-ai.cognitiveservices.azure.com/"; + + let mock_provider = setup_mock_provider_for_deletion(true); + let mut executor = SingleControllerExecutor::builder() + .resource(basic_ai()) + .controller(AzureAiController::mock_ready(account_name, endpoint)) + .platform(Platform::Azure) + .service_provider(mock_provider) + .build() + .await + .unwrap(); + + executor.delete().unwrap(); + executor.run_until_terminal().await.unwrap(); + + assert_eq!(executor.status(), ResourceStatus::Deleted); + } +} diff --git a/crates/alien-infra/src/ai/azure_import.rs b/crates/alien-infra/src/ai/azure_import.rs new file mode 100644 index 000000000..1c625dc5d --- /dev/null +++ b/crates/alien-infra/src/ai/azure_import.rs @@ -0,0 +1,35 @@ +//! Importer for Azure AI (AIServices / Azure AI Foundry). + +use alien_core::{ + import::{data::AzureAiImportData, ImportContext}, + Result, StackResourceState, +}; + +use crate::ai::azure::AzureAiState; +use crate::ai::AzureAiController; +use crate::import::ResourceImporter; +use crate::import_helpers::make_imported_state; + +/// Azure AI importer. +#[derive(Debug, Default)] +pub struct AzureAiImporter; + +impl ResourceImporter for AzureAiImporter { + type ImportData = AzureAiImportData; + + fn import( + &self, + data: AzureAiImportData, + ctx: &ImportContext<'_>, + ) -> Result { + let controller = AzureAiController { + state: AzureAiState::Ready, + account_name: Some(data.account_name), + endpoint: Some(data.endpoint), + resource_group: Some(data.resource_group), + location: Some(data.location), + _internal_stay_count: None, + }; + make_imported_state(controller, ctx) + } +} diff --git a/crates/alien-infra/src/ai/gcp.rs b/crates/alien-infra/src/ai/gcp.rs new file mode 100644 index 000000000..278e451ea --- /dev/null +++ b/crates/alien-infra/src/ai/gcp.rs @@ -0,0 +1,335 @@ +use std::time::Duration; + +use tracing::info; + +use crate::core::{ResourceControllerContext, ResourcePermissionsHelper}; +use crate::error::{ErrorData, Result}; +use alien_core::{ + bindings::AiBinding, Ai, AiHeartbeatData, AiHeartbeatStatus, AiOutputs, + GcpVertexAiHeartbeatData, HeartbeatBackend, Platform, ResourceHeartbeat, ResourceHeartbeatData, + ResourceOutputs, ResourceStatus, +}; +use alien_error::{AlienError, Context, IntoAlienError}; +use alien_gcp_clients::iam::IamPolicy; +use alien_gcp_clients::resource_manager::GetPolicyOptions; +use alien_macros::controller; +use chrono::Utc; + +#[controller] +pub struct GcpAiController { + /// GCP project ID. None until create_start runs. + pub(crate) project: Option, + /// GCP region (location) for the Vertex AI endpoint. None until create_start runs. + pub(crate) location: Option, +} + +#[controller] +impl GcpAiController { + // ─────────────── CREATE FLOW ────────────────────────────── + + #[flow_entry(Create)] + #[handler( + state = CreateStart, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn create_start( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let gcp_config = ctx.get_gcp_config()?; + let config = ctx.desired_resource_config::()?; + + self.project = Some(gcp_config.project_id.clone()); + self.location = Some(gcp_config.region.clone()); + + info!( + id = %config.id, + project = %gcp_config.project_id, + location = %gcp_config.region, + "GCP AI (Vertex AI) controller: enabling API" + ); + + Ok(HandlerAction::Continue { + state: EnablingApi, + suggested_delay: None, + }) + } + + #[handler( + state = EnablingApi, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn enabling_api( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let gcp_config = ctx.get_gcp_config()?; + let config = ctx.desired_resource_config::()?; + let client = ctx + .service_provider + .get_gcp_service_usage_client(gcp_config)?; + + info!( + id = %config.id, + project = %gcp_config.project_id, + "Enabling aiplatform.googleapis.com API" + ); + + client + .enable_service("aiplatform.googleapis.com".to_string()) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to enable aiplatform.googleapis.com".to_string(), + resource_id: Some(config.id.clone()), + })?; + + info!( + id = %config.id, + "aiplatform.googleapis.com enabled (or already enabled)" + ); + + Ok(HandlerAction::Continue { + state: ApplyingResourcePermissions, + suggested_delay: None, + }) + } + + #[handler( + state = ApplyingResourcePermissions, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn applying_resource_permissions( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let gcp_config = ctx.get_gcp_config()?; + let config = ctx.desired_resource_config::()?; + let rm_client = ctx + .service_provider + .get_gcp_resource_manager_client(gcp_config)?; + let project_id = gcp_config.project_id.clone(); + let config_id = config.id.clone(); + + info!( + id = %config.id, + project = %project_id, + "Applying Vertex AI resource-scoped permissions (custom predict-only role)" + ); + + ResourcePermissionsHelper::apply_gcp_resource_scoped_permissions( + ctx, + &config.id, + &config.id, + "GCP AI", + "ai", + rm_client, + |rm_client, desired_policy| async move { + // Project-level IAM requires read-modify-write to avoid clobbering + // bindings owned by other controllers. + let current_policy = rm_client + .get_project_iam_policy( + project_id.clone(), + Some(GetPolicyOptions { + requested_policy_version: Some(3), + }), + ) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to get project IAM policy before applying AI permissions" + .to_string(), + resource_id: Some(config_id.clone()), + })?; + + let owned_exact_roles = + ResourcePermissionsHelper::gcp_predefined_role_names(&desired_policy.bindings); + let mut all_bindings = current_policy.bindings; + + // Reconcile each member/role binding separately so we only touch what + // belongs to this stack's workload service accounts. + for desired_binding in &desired_policy.bindings { + for member in &desired_binding.members { + ResourcePermissionsHelper::reconcile_gcp_project_member_bindings( + &mut all_bindings, + vec![desired_binding.clone()], + member, + &[], + &owned_exact_roles, + ); + } + } + + let new_policy = IamPolicy::builder() + .version(3) + .bindings(all_bindings) + .maybe_etag(current_policy.etag) + .maybe_kind(current_policy.kind) + .maybe_resource_id(current_policy.resource_id) + .build(); + + rm_client + .set_project_iam_policy(project_id.clone(), new_policy, None) + .await + .context(ErrorData::CloudPlatformError { + message: "Failed to set project IAM policy for Vertex AI".to_string(), + resource_id: Some(config_id.clone()), + })?; + + info!( + project = %project_id, + "Applied the custom predict-only role at project scope for Vertex AI" + ); + + Ok(()) + }, + ) + .await?; + + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }) + } + + // ─────────────── READY STATE ──────────────────────────────── + // Loops as a heartbeat tick; Vertex AI has no per-stack resource to poll. + + #[handler( + state = Ready, + on_failure = RefreshFailed, + status = ResourceStatus::Running, + )] + async fn ready(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + let project = self.project.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + resource_id: Some(config.id.clone()), + message: "Project not set in state".to_string(), + }) + })?; + let location = self.location.as_ref().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + resource_id: Some(config.id.clone()), + message: "Location not set in state".to_string(), + }) + })?; + info!(id = %config.id, "GCP AI heartbeat tick"); + ctx.emit_heartbeat(ResourceHeartbeat { + deployment_id: None, + resource_id: config.id.clone(), + resource_type: Ai::RESOURCE_TYPE, + controller_platform: Platform::Gcp, + backend: HeartbeatBackend::Gcp, + observed_at: Utc::now(), + data: ResourceHeartbeatData::Ai(AiHeartbeatData::GcpVertex( + GcpVertexAiHeartbeatData { + status: AiHeartbeatStatus::default(), + project: project.clone(), + location: location.clone(), + }, + )), + raw: vec![], + }); + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: Some(Duration::from_secs(30)), + }) + } + + // ─────────────── UPDATE FLOW ────────────────────────────── + // Ai has no mutable fields -- update is a no-op that also recovers RefreshFailed. + + #[flow_entry(Update, from = [Ready, RefreshFailed])] + #[handler( + state = UpdateStart, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_start( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let config = ctx.desired_resource_config::()?; + info!(id = %config.id, "GCP AI update (no-op -- no mutable fields)"); + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }) + } + + // ─────────────── DELETE FLOW ────────────────────────────── + // GCP AI creates no cloud resource; deletion is always a no-op. + // The shared aiplatform API is not disabled on delete (other stacks may use it). + + #[flow_entry(Delete)] + #[handler( + state = DeleteStart, + on_failure = DeleteFailed, + status = ResourceStatus::Deleting, + )] + async fn delete_start( + &mut self, + ctx: &ResourceControllerContext<'_>, + ) -> Result { + let config = ctx.desired_resource_config::()?; + info!( + id = %config.id, + "GCP AI delete (no-op -- Vertex AI has no per-stack resource to remove)" + ); + Ok(HandlerAction::Continue { + state: Deleted, + suggested_delay: None, + }) + } + + // ─────────────── TERMINALS ──────────────────────────────── + + terminal_state!( + state = CreateFailed, + status = ResourceStatus::ProvisionFailed + ); + + terminal_state!(state = UpdateFailed, status = ResourceStatus::UpdateFailed); + + terminal_state!(state = DeleteFailed, status = ResourceStatus::DeleteFailed); + + terminal_state!( + state = RefreshFailed, + status = ResourceStatus::RefreshFailed + ); + + terminal_state!(state = Deleted, status = ResourceStatus::Deleted); + + fn build_outputs(&self) -> Option { + let project = self.project.as_ref()?; + let location = self.location.as_ref()?; + Some(ResourceOutputs::new(AiOutputs { + provider: "vertex".into(), + endpoint: Some(format!( + "https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/openapi" + )), + account: None, + })) + } + + fn get_binding_params(&self) -> Result> { + // Not-ready is Ok(None), not Err: the executor calls this on every step + // commit (including the pre-provision path), and routing a missing + // project/location through the error channel would corrupt its retry + // accounting. Err is reserved for a real serialization failure below. + let (Some(project), Some(location)) = (self.project.as_ref(), self.location.as_ref()) + else { + return Ok(None); + }; + Ok(Some( + serde_json::to_value(AiBinding::vertex(project, location)) + .into_alien_error() + .context(ErrorData::ResourceStateSerializationFailed { + resource_id: "binding".to_string(), + message: "Failed to serialize AI binding parameters".to_string(), + })?, + )) + } +} diff --git a/crates/alien-infra/src/ai/gcp_import.rs b/crates/alien-infra/src/ai/gcp_import.rs new file mode 100644 index 000000000..0de6725b7 --- /dev/null +++ b/crates/alien-infra/src/ai/gcp_import.rs @@ -0,0 +1,36 @@ +//! Importer for GCP AI (Vertex AI). +//! +//! GCP AI creates no cloud resource; the importer carries project and location +//! from the import payload directly into the controller state. + +use alien_core::{ + import::{data::GcpAiImportData, ImportContext}, + Result, StackResourceState, +}; + +use crate::ai::gcp::GcpAiState; +use crate::ai::GcpAiController; +use crate::import::ResourceImporter; +use crate::import_helpers::make_imported_state; + +/// GCP Vertex AI importer. +#[derive(Debug, Default)] +pub struct GcpAiImporter; + +impl ResourceImporter for GcpAiImporter { + type ImportData = GcpAiImportData; + + fn import( + &self, + data: GcpAiImportData, + ctx: &ImportContext<'_>, + ) -> Result { + let controller = GcpAiController { + state: GcpAiState::Ready, + project: Some(data.project_id), + location: Some(data.location), + _internal_stay_count: None, + }; + make_imported_state(controller, ctx) + } +} diff --git a/crates/alien-infra/src/ai/local.rs b/crates/alien-infra/src/ai/local.rs new file mode 100644 index 000000000..3a06697b7 --- /dev/null +++ b/crates/alien-infra/src/ai/local.rs @@ -0,0 +1,203 @@ +use std::time::Duration; + +use tracing::info; + +use crate::core::ResourceControllerContext; +use crate::error::{ErrorData, Result}; +use alien_core::{ + bindings::AiBinding, Ai, AiHeartbeatData, AiHeartbeatStatus, AiOutputs, ExternalAiHeartbeatData, + HeartbeatBackend, Platform, ResourceHeartbeat, ResourceHeartbeatData, ResourceOutputs, + ResourceStatus, +}; +use alien_error::{AlienError, Context, IntoAlienError}; +use alien_macros::controller; +use chrono::Utc; + +/// The env var the developer sets to bring their own provider key for local dev. +const API_KEY_ENV: &str = AiBinding::LOCAL_API_KEY_ENV; +/// The default provider when running locally; the SDK maps it to the provider's +/// OpenAI-compatible base URL (overridable via `ALIEN_AI_LOCAL_BASE_URL`). +const DEFAULT_PROVIDER: &str = AiBinding::LOCAL_DEFAULT_PROVIDER; + +/// Local AI controller. There is no cloud LLM to provision and no ambient identity on +/// a developer's machine, so the developer *brings their own key* (`OPENAI_API_KEY`) and +/// the app calls the provider directly. `get_binding_params` syncs the key-less binding +/// coordinates; the key itself reaches a linked workload through the runtime-only channel +/// (`LocalBindingsProvider::resolve_runtime_only_binding_env`), so it never enters +/// persisted or control-plane-synced state. +#[controller] +pub struct LocalAiController { + /// The BYO-key provider (e.g. "openai"). None until create_start runs. + pub(crate) provider: Option, +} + +#[controller] +impl LocalAiController { + // ─────────────── CREATE FLOW ────────────────────────────── + + #[flow_entry(Create)] + #[handler( + state = CreateStart, + on_failure = CreateFailed, + status = ResourceStatus::Provisioning, + )] + async fn create_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + + // Fail loud if the developer hasn't supplied a key — local AI has no ambient + // identity to fall back on, so this is a required, actionable precondition. + if std::env::var(API_KEY_ENV).map(|k| k.is_empty()).unwrap_or(true) { + return Err(AlienError::new(ErrorData::ResourceConfigInvalid { + resource_id: Some(config.id.clone()), + message: format!( + "local AI resource '{}' needs a provider API key: set {API_KEY_ENV} before `alien dev`", + config.id + ), + })); + } + + self.provider = Some(DEFAULT_PROVIDER.to_string()); + info!(id=%config.id, provider=DEFAULT_PROVIDER, "Local AI (BYO-key) controller: no resource to create"); + + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }) + } + + // ─────────────── READY STATE ──────────────────────────────── + // Loops as a heartbeat tick; there is no per-stack resource to poll. + + #[handler( + state = Ready, + on_failure = RefreshFailed, + status = ResourceStatus::Running, + )] + async fn ready(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + let provider = self.provider.clone().ok_or_else(|| { + AlienError::new(ErrorData::ResourceConfigInvalid { + resource_id: Some(config.id.clone()), + message: "Provider not set in state".to_string(), + }) + })?; + ctx.emit_heartbeat(ResourceHeartbeat { + deployment_id: None, + resource_id: config.id.clone(), + resource_type: Ai::RESOURCE_TYPE, + controller_platform: Platform::Local, + backend: HeartbeatBackend::Local, + observed_at: Utc::now(), + data: ResourceHeartbeatData::Ai(AiHeartbeatData::External(ExternalAiHeartbeatData { + status: AiHeartbeatStatus::default(), + provider, + })), + raw: vec![], + }); + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: Some(Duration::from_secs(30)), + }) + } + + // ─────────────── UPDATE FLOW ────────────────────────────── + // Ai has no mutable fields -- update is a no-op that also recovers RefreshFailed. + + #[flow_entry(Update, from = [Ready, RefreshFailed])] + #[handler( + state = UpdateStart, + on_failure = UpdateFailed, + status = ResourceStatus::Updating, + )] + async fn update_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + info!(id=%config.id, "Local AI update (no-op -- no mutable fields)"); + Ok(HandlerAction::Continue { + state: Ready, + suggested_delay: None, + }) + } + + // ─────────────── DELETE FLOW ────────────────────────────── + // No cloud resource is created; deletion is always a no-op. + + #[flow_entry(Delete)] + #[handler( + state = DeleteStart, + on_failure = DeleteFailed, + status = ResourceStatus::Deleting, + )] + async fn delete_start(&mut self, ctx: &ResourceControllerContext<'_>) -> Result { + let config = ctx.desired_resource_config::()?; + info!(id=%config.id, "Local AI delete (no-op -- nothing was provisioned)"); + Ok(HandlerAction::Continue { + state: Deleted, + suggested_delay: None, + }) + } + + // ─────────────── TERMINALS ──────────────────────────────── + + terminal_state!(state = CreateFailed, status = ResourceStatus::ProvisionFailed); + terminal_state!(state = UpdateFailed, status = ResourceStatus::UpdateFailed); + terminal_state!(state = DeleteFailed, status = ResourceStatus::DeleteFailed); + terminal_state!(state = RefreshFailed, status = ResourceStatus::RefreshFailed); + terminal_state!(state = Deleted, status = ResourceStatus::Deleted); + + fn build_outputs(&self) -> Option { + let provider = self.provider.as_ref()?; + Some(ResourceOutputs::new(AiOutputs { + provider: provider.clone(), + endpoint: None, + account: None, + })) + } + + fn get_binding_params(&self) -> Result> { + // Synced channel (control-plane `remote_binding_params`): emit the provider only, + // with the key stripped so a BYO-key never reaches synced/persisted state. The + // gateway reads the key from the never-synced worker-env channel below. + let provider = match &self.provider { + Some(p) => p, + None => return Ok(None), + }; + let mut value = serde_json::to_value(AiBinding::external(provider.clone(), String::new())) + .into_alien_error() + .context(ErrorData::ResourceStateSerializationFailed { + resource_id: "binding".to_string(), + message: "Failed to serialize AI binding parameters".to_string(), + })?; + if let Some(obj) = value.as_object_mut() { + obj.remove("apiKey"); + } + Ok(Some(value)) + } + +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::ResourceController; + + #[test] + fn synced_binding_carries_the_provider_but_never_the_key() { + // The control-plane-synced channel must expose the provider (so the resource's + // shape is visible) while stripping the BYO key -- a secret never belongs in + // synced or persisted state. The worker-env channel (resolve_binding_params) + // is where the key travels, and it is never synced. + let controller = LocalAiController { + provider: Some("openai".to_string()), + ..Default::default() + }; + let value = controller + .get_binding_params() + .expect("binding params serialize") + .expect("a provisioned controller emits a binding"); + assert_eq!(value.get("provider").and_then(|v| v.as_str()), Some("openai")); + assert!( + value.get("apiKey").is_none(), + "the synced binding must never carry the provider key: {value}" + ); + } +} diff --git a/crates/alien-infra/src/ai/mod.rs b/crates/alien-infra/src/ai/mod.rs new file mode 100644 index 000000000..852221051 --- /dev/null +++ b/crates/alien-infra/src/ai/mod.rs @@ -0,0 +1,32 @@ +pub mod aws; +pub use aws::AwsAiController; + +#[cfg(feature = "aws")] +mod aws_import; +#[cfg(feature = "aws")] +pub use aws_import::AwsAiImporter; + +#[cfg(feature = "gcp")] +pub mod gcp; +#[cfg(feature = "gcp")] +pub use gcp::GcpAiController; + +#[cfg(feature = "gcp")] +mod gcp_import; +#[cfg(feature = "gcp")] +pub use gcp_import::GcpAiImporter; + +#[cfg(feature = "azure")] +pub mod azure; +#[cfg(feature = "azure")] +pub use azure::AzureAiController; + +#[cfg(feature = "azure")] +mod azure_import; +#[cfg(feature = "azure")] +pub use azure_import::AzureAiImporter; + +#[cfg(feature = "local")] +pub mod local; +#[cfg(feature = "local")] +pub use local::LocalAiController; diff --git a/crates/alien-infra/src/aws_importers.rs b/crates/alien-infra/src/aws_importers.rs index 7cc96587c..0de60ae47 100644 --- a/crates/alien-infra/src/aws_importers.rs +++ b/crates/alien-infra/src/aws_importers.rs @@ -11,11 +11,12 @@ #[cfg(feature = "kubernetes")] use alien_core::KubernetesCluster; use alien_core::{ - ArtifactRegistry, AwsOpenSearch, Build, Email, Kv, Network, Platform, Queue, Storage, Vault, + Ai, ArtifactRegistry, AwsOpenSearch, Build, Email, Kv, Network, Platform, Queue, Storage, Vault, Worker, }; use alien_core::{RemoteStackManagement, ServiceAccount}; +use crate::ai::AwsAiImporter; use crate::artifact_registry::AwsArtifactRegistryImporter; use crate::build::AwsBuildImporter; use crate::email::AwsEmailImporter; @@ -35,6 +36,7 @@ use crate::ImporterRegistry; /// Register every OSS AWS importer with `registry`. pub fn register(registry: &mut ImporterRegistry) { registry + .register(Ai::RESOURCE_TYPE, Platform::Aws, AwsAiImporter) .register(Storage::RESOURCE_TYPE, Platform::Aws, AwsStorageImporter) .register(Kv::RESOURCE_TYPE, Platform::Aws, AwsKvImporter) .register(Vault::RESOURCE_TYPE, Platform::Aws, AwsVaultImporter) diff --git a/crates/alien-infra/src/azure_importers.rs b/crates/alien-infra/src/azure_importers.rs index a713aeb3b..baec7cd5a 100644 --- a/crates/alien-infra/src/azure_importers.rs +++ b/crates/alien-infra/src/azure_importers.rs @@ -12,11 +12,12 @@ #[cfg(feature = "kubernetes")] use alien_core::KubernetesCluster; use alien_core::{ - ArtifactRegistry, AzureContainerAppsEnvironment, AzureResourceGroup, AzureServiceBusNamespace, - AzureStorageAccount, Build, Kv, Network, Platform, Queue, RemoteStackManagement, - ServiceAccount, ServiceActivation, Storage, Vault, Worker, + Ai, ArtifactRegistry, AzureContainerAppsEnvironment, AzureResourceGroup, + AzureServiceBusNamespace, AzureStorageAccount, Build, Kv, Network, Platform, Queue, + RemoteStackManagement, ServiceAccount, ServiceActivation, Storage, Vault, Worker, }; +use crate::ai::AzureAiImporter; use crate::artifact_registry::AzureArtifactRegistryImporter; use crate::build::AzureBuildImporter; #[cfg(feature = "kubernetes")] @@ -39,6 +40,7 @@ use crate::ImporterRegistry; pub fn register(registry: &mut ImporterRegistry) { registry // Main resources + .register(Ai::RESOURCE_TYPE, Platform::Azure, AzureAiImporter) .register( Storage::RESOURCE_TYPE, Platform::Azure, diff --git a/crates/alien-infra/src/container/local.rs b/crates/alien-infra/src/container/local.rs index 2dcf3e5ca..2f570c06a 100644 --- a/crates/alien-infra/src/container/local.rs +++ b/crates/alien-infra/src/container/local.rs @@ -252,35 +252,40 @@ impl LocalContainerController { // same-name value from the deployment environment snapshot. env_vars.extend(crate::core::direct_monitoring_auth_headers(ctx)); - // Local Postgres inlines its password in the binding (no secret store) and `get_binding_params` - // strips it from the synced copy, so re-resolve the full binding from the manager's 0600 - // metadata straight into the container env. - for link in &config.links { - if link.resource_type() != &Postgres::RESOURCE_TYPE { - continue; - } - let manager = ctx + // A linked binding that carries a runtime-only secret (Postgres password, BYO-key AI) is + // stripped from the synced copy by `get_binding_params`; re-resolve the full binding + // straight into the container env, which is never persisted in Alien state. Unlike the + // worker recover path, at container start the linked resource must exist — fail loud if + // nothing resolves. + for binding_ref in alien_local::RuntimeOnlyBindingRef::from_links(&config.links) { + let bindings_provider = ctx .service_provider - .get_local_postgres_manager() + .get_local_bindings_provider() .ok_or_else(|| { AlienError::new(ErrorData::LocalServicesNotAvailable { - service_name: "postgres_manager".to_string(), + service_name: "bindings_provider".to_string(), }) })?; - let binding = manager.get_binding(link.id()).context( - ErrorData::ResourceControllerConfigError { - resource_id: link.id().to_string(), - message: format!("Failed to read binding for local Postgres '{}'", link.id()), - }, - )?; - env_vars.extend( - alien_core::bindings::serialize_binding_as_env_var(link.id(), &binding).context( - ErrorData::ResourceControllerConfigError { - resource_id: link.id().to_string(), - message: "Failed to serialize Postgres binding".to_string(), - }, - )?, - ); + let entry = bindings_provider + .resolve_runtime_only_binding_env(&binding_ref.name, &binding_ref.resource_type) + .await + .context(ErrorData::ResourceControllerConfigError { + resource_id: binding_ref.name.clone(), + message: format!( + "Failed to resolve runtime-only binding '{}'", + binding_ref.name + ), + })? + .ok_or_else(|| { + AlienError::new(ErrorData::ResourceControllerConfigError { + resource_id: binding_ref.name.clone(), + message: format!( + "runtime-only binding '{}' resolved to nothing", + binding_ref.name + ), + }) + })?; + env_vars.extend(entry); } // Rewrite binding env vars to use container paths instead of host paths diff --git a/crates/alien-infra/src/core/controller.rs b/crates/alien-infra/src/core/controller.rs index b1e4907ce..b4dd32247 100644 --- a/crates/alien-infra/src/core/controller.rs +++ b/crates/alien-infra/src/core/controller.rs @@ -1108,6 +1108,16 @@ fn deserialize_controller_by_tag( deser!(crate::remote_stack_management::TestRemoteStackManagementController) } + // AI controllers + #[cfg(feature = "aws")] + "AwsAiController" => deser!(crate::ai::AwsAiController), + #[cfg(feature = "gcp")] + "GcpAiController" => deser!(crate::ai::GcpAiController), + #[cfg(feature = "azure")] + "AzureAiController" => deser!(crate::ai::AzureAiController), + #[cfg(feature = "local")] + "LocalAiController" => deser!(crate::ai::LocalAiController), + // Service activation controllers #[cfg(feature = "gcp")] "GcpServiceActivationController" => { diff --git a/crates/alien-infra/src/core/environment_variables.rs b/crates/alien-infra/src/core/environment_variables.rs index b85da8388..75a00ff6b 100644 --- a/crates/alien-infra/src/core/environment_variables.rs +++ b/crates/alien-infra/src/core/environment_variables.rs @@ -491,38 +491,38 @@ impl EnvironmentVariableBuilder { // Check if there's an external binding for this resource match ctx.deployment_config.external_bindings.get(binding_name) { Some(external) => { - let value = match external { - alien_core::ExternalBinding::Storage(b) => serde_json::to_value(b), - alien_core::ExternalBinding::Queue(b) => serde_json::to_value(b), - alien_core::ExternalBinding::Kv(b) => serde_json::to_value(b), - alien_core::ExternalBinding::ArtifactRegistry(b) => { - serde_json::to_value(b) - } - alien_core::ExternalBinding::Vault(b) => serde_json::to_value(b), - alien_core::ExternalBinding::ContainerAppsEnvironment(b) => { - serde_json::to_value(b) - } - alien_core::ExternalBinding::Postgres(b) => { - // External (Remote Access) Postgres on the local platform: the - // binding inlines a raw password with no local manager to - // re-resolve it, so it would persist into worker metadata. - if ctx.platform == alien_core::Platform::Local { - return Err(AlienError::new(ErrorData::ResourceConfigInvalid { - message: "external (Remote Access) Postgres is not supported on the local platform".to_string(), - resource_id: Some(binding_name.to_string()), - })); + // External Postgres and AI on the local platform: the binding + // inlines a raw secret, and the local runtime-only channel would + // either persist it into worker metadata (Postgres) or silently + // replace it with the dev key from the process environment (AI). + if ctx.platform == alien_core::Platform::Local + && matches!( + external, + alien_core::ExternalBinding::Postgres(_) + | alien_core::ExternalBinding::Ai(_) + ) + { + let kind = match external { + alien_core::ExternalBinding::Postgres(_) => { + "external (Remote Access) Postgres" } - serde_json::to_value(b) - } + _ => "an external (BYO-key) AI binding", + }; + return Err(AlienError::new(ErrorData::ResourceConfigInvalid { + message: format!( + "{kind} is not supported on the local platform" + ), + resource_id: Some(binding_name.to_string()), + })); } - .into_alien_error() - .context( - ErrorData::ResourceStateSerializationFailed { + let value = external + .to_env_binding_value() + .into_alien_error() + .context(ErrorData::ResourceStateSerializationFailed { resource_id: binding_name.to_string(), message: "Failed to serialize external binding parameters" .to_string(), - }, - )?; + })?; Some(value) } None => None, @@ -659,6 +659,46 @@ mod tests { assert_eq!(bindings[0].1, binding_json); } + /// Real-path assertion for the AI external-binding env-var injection: drives the + /// PRODUCTION serialization (`ExternalBinding::to_env_binding_value`, the same + /// call `add_linked_resources` makes) through `serialize_binding_as_env_var` and + /// asserts the `ALIEN__BINDING` value is the exact service-tagged JSON the + /// SDK's `ai(name)` parser consumes. A drift here (e.g. dropping the + /// `AiBinding::External` wrap) silently routes external OpenAI/Anthropic to the + /// wrong upstream. + #[test] + fn test_ai_external_binding_injects_service_tagged_env_var() { + use alien_core::bindings::{ + binding_env_var_name, serialize_binding_as_env_var, ExternalAiBinding, + }; + use alien_core::ExternalBinding; + + let external = ExternalBinding::Ai(ExternalAiBinding { + provider: "openai".to_string(), + api_key: "sk-test".into(), + }); + + let value = external + .to_env_binding_value() + .expect("external AI binding should serialize"); + + let env = serialize_binding_as_env_var("llm", &value).unwrap(); + let key = binding_env_var_name("llm"); + assert_eq!(key, "ALIEN_LLM_BINDING"); + + let injected: serde_json::Value = + serde_json::from_str(env.get(&key).expect("binding env var present")).unwrap(); + assert_eq!( + injected, + json!({ + "service": "external", + "provider": "openai", + "apiKey": "sk-test", + }), + "injected AI binding env var must match the SDK's expected service-tagged shape" + ); + } + #[test] fn test_build_without_bindings_returns_empty_list() { let env = HashMap::from([("FOO".to_string(), "bar".to_string())]); diff --git a/crates/alien-infra/src/core/executor_tests/edge_cases_tests.rs b/crates/alien-infra/src/core/executor_tests/edge_cases_tests.rs index d75b4552c..7afe2c0d7 100644 --- a/crates/alien-infra/src/core/executor_tests/edge_cases_tests.rs +++ b/crates/alien-infra/src/core/executor_tests/edge_cases_tests.rs @@ -4,9 +4,9 @@ use super::helpers::*; use crate::core::StackExecutor; use crate::error::Result; use alien_core::{ - ClientConfig, DeploymentConfig, EnvironmentVariablesSnapshot, ExternalBinding, - ExternalBindings, Resource, ResourceLifecycle, ResourceRef, ResourceStatus, Stack, - StackSettings, Storage, StorageBinding, + bindings::ExternalAiBinding, Ai, ClientConfig, DeploymentConfig, EnvironmentVariablesSnapshot, + ExternalBinding, ExternalBindings, Resource, ResourceLifecycle, ResourceRef, ResourceStatus, + Stack, StackSettings, Storage, StorageBinding, }; fn external_storage_config(resource_id: &str) -> DeploymentConfig { @@ -28,6 +28,28 @@ fn external_storage_config(resource_id: &str) -> DeploymentConfig { .build() } +fn external_ai_config(resource_id: &str) -> DeploymentConfig { + let mut external_bindings = ExternalBindings::new(); + external_bindings.insert( + resource_id, + ExternalBinding::Ai(ExternalAiBinding { + provider: "openai".to_string(), + api_key: "sk-test".into(), + }), + ); + + DeploymentConfig::builder() + .stack_settings(StackSettings::default()) + .environment_variables(EnvironmentVariablesSnapshot { + variables: vec![], + hash: String::new(), + created_at: String::new(), + }) + .external_bindings(external_bindings) + .allow_frozen_changes(false) + .build() +} + async fn assert_best_effort_delete_error_marks_deleted(flag: &str) -> Result<()> { let mut func1 = test_function("func1"); func1 @@ -563,6 +585,37 @@ async fn test_external_binding_resource_syncs_without_controller() -> Result<()> Ok(()) } +/// An external (BYO-key) AI resource must be treated as an external binding: +/// the cloud AI controller (Bedrock/Vertex/Foundry) is SKIPPED and the resource +/// syncs Running with no controller state. Without this, an external OpenAI +/// resource would silently provision and route to Bedrock. +#[tokio::test] +async fn test_external_ai_binding_skips_cloud_controller() -> Result<()> { + let ai = Ai::new("llm".to_string()).build(); + let stack = Stack::new("external-ai-sync-test".to_owned()) + .add(ai.clone(), ResourceLifecycle::Frozen) + .build(); + let deployment_config = external_ai_config("llm"); + let executor = StackExecutor::builder(&stack, ClientConfig::Test) + .deployment_config(&deployment_config) + .build()?; + + let state = run_to_synced(&executor, new_test_state()).await?; + let external_state = state.resources.get("llm").unwrap(); + + assert_eq!(external_state.status, ResourceStatus::Running); + assert!( + external_state.internal_state.is_none(), + "External AI binding must not run the cloud controller (no controller state)" + ); + assert!( + executor.is_synced(&state), + "Desired external AI binding should be considered synced while Running" + ); + + Ok(()) +} + /// Full-stack deletion should mark controllerless external bindings deleted /// instead of looping forever waiting for a controller that intentionally /// does not exist. diff --git a/crates/alien-infra/src/core/registry.rs b/crates/alien-infra/src/core/registry.rs index d2942f635..74c2a5dbf 100644 --- a/crates/alien-infra/src/core/registry.rs +++ b/crates/alien-infra/src/core/registry.rs @@ -15,7 +15,7 @@ use alien_core::Daemon; #[cfg(feature = "local")] use alien_core::Postgres; use alien_core::{ - ArtifactRegistry, AwsOpenSearch, AzureContainerAppsEnvironment, AzureResourceGroup, + Ai, ArtifactRegistry, AwsOpenSearch, AzureContainerAppsEnvironment, AzureResourceGroup, AzureServiceBusNamespace, AzureStorageAccount, Build, Email, Kv, Network, RemoteStackManagement, ServiceAccount, ServiceActivation, Storage, Vault, Worker, }; @@ -669,6 +669,32 @@ impl ResourceRegistry { >::new()), ); + // Register built-in Ai controllers + #[cfg(feature = "aws")] + registry.register_controller_factory( + Ai::RESOURCE_TYPE, + Platform::Aws, + Box::new(DefaultControllerFactory::::new()), + ); + #[cfg(feature = "gcp")] + registry.register_controller_factory( + Ai::RESOURCE_TYPE, + Platform::Gcp, + Box::new(DefaultControllerFactory::::new()), + ); + #[cfg(feature = "azure")] + registry.register_controller_factory( + Ai::RESOURCE_TYPE, + Platform::Azure, + Box::new(DefaultControllerFactory::::new()), + ); + #[cfg(feature = "local")] + registry.register_controller_factory( + Ai::RESOURCE_TYPE, + Platform::Local, + Box::new(DefaultControllerFactory::::new()), + ); + // Register Local ComputeCluster controller #[cfg(feature = "local")] registry.register_controller_factory( diff --git a/crates/alien-infra/src/core/service_provider.rs b/crates/alien-infra/src/core/service_provider.rs index 512159c1f..2219bd2b9 100644 --- a/crates/alien-infra/src/core/service_provider.rs +++ b/crates/alien-infra/src/core/service_provider.rs @@ -46,6 +46,7 @@ use alien_azure_clients::{ AzureServiceBusDataPlaneClient, AzureServiceBusManagementClient, ServiceBusDataPlaneApi, ServiceBusManagementApi, }, + cognitive_services::{AzureCognitiveServicesClient, CognitiveServicesAccountsApi}, storage_accounts::{AzureStorageAccountsClient, StorageAccountsApi}, tables::{AzureTableManagementClient, TableManagementApi}, AzureClientConfig, AzureTokenCache, @@ -225,6 +226,10 @@ pub trait PlatformServiceProvider: Send + Sync { &self, config: &AzureClientConfig, ) -> Result>; + fn get_azure_cognitive_services_client( + &self, + config: &AzureClientConfig, + ) -> Result>; fn get_azure_key_vault_management_client( &self, config: &AzureClientConfig, @@ -948,6 +953,16 @@ impl PlatformServiceProvider for DefaultPlatformServiceProvider { ))) } + fn get_azure_cognitive_services_client( + &self, + config: &AzureClientConfig, + ) -> Result> { + Ok(Arc::new(AzureCognitiveServicesClient::new( + reqwest::Client::new(), + AzureTokenCache::new(config.clone()), + ))) + } + fn get_azure_key_vault_management_client( &self, config: &AzureClientConfig, diff --git a/crates/alien-infra/src/daemon/local.rs b/crates/alien-infra/src/daemon/local.rs index 071380212..da37cb104 100644 --- a/crates/alien-infra/src/daemon/local.rs +++ b/crates/alien-infra/src/daemon/local.rs @@ -12,7 +12,7 @@ use crate::error::{ErrorData, Result}; use alien_core::{ Daemon, DaemonCode, DaemonHeartbeatData, DaemonOutputs, HeartbeatBackend, LocalDaemonHeartbeatData, LocalRuntimeUnitKind, LocalRuntimeUnitStatus, ObservedHealth, - Platform, Postgres, ProviderLifecycleState, ResourceHeartbeat, ResourceHeartbeatData, + Platform, ProviderLifecycleState, ResourceHeartbeat, ResourceHeartbeatData, ResourceOutputs, ResourceStatus, WorkloadHeartbeatStatus, }; use alien_error::{AlienError, Context}; @@ -159,21 +159,16 @@ impl LocalDaemonController { runtime_only_env_names.sort(); runtime_only_env_names.dedup(); - // Linked Postgres resources carry a runtime-only secret (the password). Name them so the - // worker manager delivers the binding to the process but never persists it to metadata. - let runtime_only_binding_names: Vec = config - .links - .iter() - .filter(|link| link.resource_type() == &Postgres::RESOURCE_TYPE) - .map(|link| link.id().to_string()) - .collect(); + // Links whose binding carries a runtime-only secret (Postgres password, BYO-key AI): the + // supervisor delivers the binding to the process but never persists it to metadata. + let runtime_only_bindings = alien_local::RuntimeOnlyBindingRef::from_links(&config.links); manager .start_daemon( &config.id, env_vars, alien_local::DaemonLaunchOptions { - runtime_only_binding_names, + runtime_only_bindings, runtime_only_env_names, command_override: config.command.clone(), stop_grace_period_seconds: config.stop_grace_period_seconds, diff --git a/crates/alien-infra/src/gcp_importers.rs b/crates/alien-infra/src/gcp_importers.rs index 53fc70826..3723345e4 100644 --- a/crates/alien-infra/src/gcp_importers.rs +++ b/crates/alien-infra/src/gcp_importers.rs @@ -5,9 +5,12 @@ #[cfg(feature = "kubernetes")] use alien_core::KubernetesCluster; -use alien_core::{ArtifactRegistry, Build, Kv, Network, Platform, Queue, Storage, Vault, Worker}; +use alien_core::{ + Ai, ArtifactRegistry, Build, Kv, Network, Platform, Queue, Storage, Vault, Worker, +}; use alien_core::{RemoteStackManagement, ServiceAccount, ServiceActivation}; +use crate::ai::GcpAiImporter; use crate::artifact_registry::GcpArtifactRegistryImporter; use crate::build::GcpBuildImporter; #[cfg(feature = "kubernetes")] @@ -26,6 +29,7 @@ use crate::ImporterRegistry; /// Register every OSS GCP importer with `registry`. pub fn register(registry: &mut ImporterRegistry) { registry + .register(Ai::RESOURCE_TYPE, Platform::Gcp, GcpAiImporter) .register(Storage::RESOURCE_TYPE, Platform::Gcp, GcpStorageImporter) .register(Kv::RESOURCE_TYPE, Platform::Gcp, GcpKvImporter) .register(Vault::RESOURCE_TYPE, Platform::Gcp, GcpVaultImporter) diff --git a/crates/alien-infra/src/lib.rs b/crates/alien-infra/src/lib.rs index b6ead7152..14798bb21 100644 --- a/crates/alien-infra/src/lib.rs +++ b/crates/alien-infra/src/lib.rs @@ -59,6 +59,19 @@ mod container; #[cfg(any(feature = "kubernetes", feature = "local"))] pub use container::*; +mod ai; +pub use ai::AwsAiController; +#[cfg(feature = "aws")] +pub use ai::AwsAiImporter; +#[cfg(feature = "gcp")] +pub use ai::GcpAiController; +#[cfg(feature = "gcp")] +pub use ai::GcpAiImporter; +#[cfg(feature = "azure")] +pub use ai::AzureAiController; +#[cfg(feature = "azure")] +pub use ai::AzureAiImporter; + mod kv; #[cfg(feature = "aws")] pub use kv::AwsKvImporter; diff --git a/crates/alien-infra/src/worker/local.rs b/crates/alien-infra/src/worker/local.rs index 580a778fe..89dc36a40 100644 --- a/crates/alien-infra/src/worker/local.rs +++ b/crates/alien-infra/src/worker/local.rs @@ -9,7 +9,7 @@ use crate::core::{ use crate::error::{ErrorData, Result}; use alien_core::{ HeartbeatBackend, LocalRuntimeUnitKind, LocalRuntimeUnitStatus, LocalWorkerHeartbeatData, - ObservedHealth, Platform, Postgres, ProviderLifecycleState, ResourceHeartbeat, + ObservedHealth, Platform, ProviderLifecycleState, ResourceHeartbeat, ResourceHeartbeatData, ResourceOutputs as CoreResourceOutputs, ResourceStatus, Worker, WorkerCode, WorkerHeartbeatData, WorkerOutputs, WorkloadHeartbeatStatus, ENV_ALIEN_COMMANDS_TOKEN, @@ -157,21 +157,16 @@ impl LocalWorkerController { runtime_only_env_names.sort(); env_vars.extend(runtime_control_env); - // Linked Postgres resources carry a runtime-only secret (the password). Name them so the - // worker manager delivers the binding to the process but never persists it to metadata. - let runtime_only_binding_names: Vec = config - .links - .iter() - .filter(|link| link.resource_type() == &Postgres::RESOURCE_TYPE) - .map(|link| link.id().to_string()) - .collect(); + // Links whose binding carries a runtime-only secret (Postgres password, BYO-key AI): + // the worker manager delivers the binding to the process but never persists it to metadata. + let runtime_only_bindings = alien_local::RuntimeOnlyBindingRef::from_links(&config.links); // Start the worker with complete environment let worker_url = func_mgr .start_worker( &config.id, env_vars, - runtime_only_binding_names, + runtime_only_bindings, runtime_only_env_names, ) .await diff --git a/crates/alien-infra/tests/importers.rs b/crates/alien-infra/tests/importers.rs index 85df2ca6e..b2e4610f5 100644 --- a/crates/alien-infra/tests/importers.rs +++ b/crates/alien-infra/tests/importers.rs @@ -24,17 +24,18 @@ use alien_core::import::{ data::{ - AwsKvImportData, AwsRemoteStackManagementImportData, AwsServiceAccountImportData, - AwsStorageImportData, AzureContainerAppsEnvironmentImportData, + AwsAiImportData, AwsKvImportData, AwsRemoteStackManagementImportData, AwsServiceAccountImportData, + AwsStorageImportData, AzureAiImportData, + AzureContainerAppsEnvironmentImportData, AzureRemoteStackManagementImportData, AzureResourceGroupImportData, AzureServiceAccountImportData, AzureStorageAccountImportData, AzureStorageImportData, - GcpBuildImportData, GcpKvImportData, GcpNetworkImportData, GcpServiceActivationImportData, + GcpAiImportData, GcpBuildImportData, GcpKvImportData, GcpNetworkImportData, GcpServiceActivationImportData, GcpStorageImportData, KubernetesClusterImportData, }, ImportContext, }; use alien_core::{ - ArtifactRegistry, AwsManagementConfig, AwsOpenSearch, AwsOpenSearchOutputs, + Ai, ArtifactRegistry, AwsManagementConfig, AwsOpenSearch, AwsOpenSearchOutputs, AzureContainerAppsEnvironment, AzureContainerAppsEnvironmentOutputs, AzureManagementConfig, AzureResourceGroup, AzureResourceGroupOutputs, AzureServiceBusNamespace, AzureStorageAccount, AzureStorageAccountOutputs, Build, Email, EmailInbound, EmailOutputs, GcpManagementConfig, @@ -864,11 +865,139 @@ fn azure_remote_stack_management_round_trip_includes_access_outputs() { ); } +#[test] +fn aws_ai_round_trip() { + let entry = entry(Ai::new("llm".to_string()).build()); + let data = AwsAiImportData { + region: "us-east-1".to_string(), + }; + let state = run_through_registry( + &Ai::RESOURCE_TYPE, + Platform::Aws, + serde_json::to_value(&data).unwrap(), + &entry, + "us-east-1", + &aws_management_config(), + ); + assert_running_with_internal_state(&state); + + // binding params must carry the region and identify Bedrock + let binding = state + .remote_binding_params + .as_ref() + .expect("AWS AI import must produce binding params"); + assert_eq!(binding["service"], "bedrock"); + assert_eq!(binding["region"], "us-east-1"); + + // outputs must expose provider "bedrock" + let outputs = state + .outputs + .as_ref() + .and_then(|o| o.downcast_ref::()) + .expect("AWS AI import must produce AiOutputs"); + assert_eq!(outputs.provider, "bedrock"); + assert!( + outputs + .endpoint + .as_ref() + .is_some_and(|ep| ep.contains("us-east-1")), + "endpoint must contain the region" + ); +} + +#[test] +fn gcp_ai_round_trip() { + let entry = entry(Ai::new("llm".to_string()).build()); + let data = GcpAiImportData { + project_id: "my-project".to_string(), + location: "us-central1".to_string(), + }; + let state = run_through_registry( + &Ai::RESOURCE_TYPE, + Platform::Gcp, + serde_json::to_value(&data).unwrap(), + &entry, + "us-central1", + &gcp_management_config(), + ); + assert_running_with_internal_state(&state); + + // binding params must carry project + location and identify Vertex AI + let binding = state + .remote_binding_params + .as_ref() + .expect("GCP AI import must produce binding params"); + assert_eq!(binding["service"], "vertex"); + assert_eq!(binding["project"], "my-project"); + assert_eq!(binding["location"], "us-central1"); + + // outputs must expose provider "vertex" + let outputs = state + .outputs + .as_ref() + .and_then(|o| o.downcast_ref::()) + .expect("GCP AI import must produce AiOutputs"); + assert_eq!(outputs.provider, "vertex"); + assert!( + outputs + .endpoint + .as_ref() + .is_some_and(|ep| ep.contains("us-central1") && ep.contains("my-project")), + "endpoint must contain the location and project" + ); +} + +#[test] +fn azure_ai_round_trip() { + let entry = entry(Ai::new("llm".to_string()).build()); + let data = AzureAiImportData { + account_name: "myprefix-llm".to_string(), + endpoint: "https://myprefix-llm.cognitiveservices.azure.com/".to_string(), + resource_group: "rg-alien".to_string(), + location: "eastus".to_string(), + }; + let state = run_through_registry( + &Ai::RESOURCE_TYPE, + Platform::Azure, + serde_json::to_value(&data).unwrap(), + &entry, + "eastus", + &azure_management_config(), + ); + assert_running_with_internal_state(&state); + + // binding params must carry the endpoint + account and identify Foundry + let binding = state + .remote_binding_params + .as_ref() + .expect("Azure AI import must produce binding params"); + assert_eq!(binding["service"], "foundry"); + assert_eq!( + binding["endpoint"], + "https://myprefix-llm.cognitiveservices.azure.com/" + ); + assert_eq!(binding["account"], "myprefix-llm"); + + // outputs must expose provider "foundry" + let outputs = state + .outputs + .as_ref() + .and_then(|o| o.downcast_ref::()) + .expect("Azure AI import must produce AiOutputs"); + assert_eq!(outputs.provider, "foundry"); + assert_eq!( + outputs.endpoint.as_deref(), + Some("https://myprefix-llm.cognitiveservices.azure.com/") + ); + assert_eq!(outputs.account.as_deref(), Some("myprefix-llm")); +} + #[test] fn registry_built_in_covers_all_oss_pairs() { let registry = ImporterRegistry::built_in(); let aws_pairs: &[ResourceType] = &[ + Ai::RESOURCE_TYPE, Storage::RESOURCE_TYPE, Kv::RESOURCE_TYPE, Vault::RESOURCE_TYPE, @@ -891,6 +1020,7 @@ fn registry_built_in_covers_all_oss_pairs() { } let gcp_pairs: &[ResourceType] = &[ + Ai::RESOURCE_TYPE, Storage::RESOURCE_TYPE, Kv::RESOURCE_TYPE, Vault::RESOURCE_TYPE, @@ -912,6 +1042,7 @@ fn registry_built_in_covers_all_oss_pairs() { } let azure_pairs: &[ResourceType] = &[ + Ai::RESOURCE_TYPE, Storage::RESOURCE_TYPE, Kv::RESOURCE_TYPE, Vault::RESOURCE_TYPE, diff --git a/crates/alien-local/src/daemon_supervisor.rs b/crates/alien-local/src/daemon_supervisor.rs index 087fd2421..195797136 100644 --- a/crates/alien-local/src/daemon_supervisor.rs +++ b/crates/alien-local/src/daemon_supervisor.rs @@ -7,7 +7,7 @@ //! worker runtime that [`crate::worker_manager`] hosts for the Worker path. use crate::error::{ErrorData, Result}; -use crate::worker_manager::{LocalWorkerManager, WorkerMetadata}; +use crate::worker_manager::{LocalWorkerManager, RuntimeOnlyBindingRef, WorkerMetadata}; use alien_error::{AlienError, Context, ContextError as _, IntoAlienError}; use alien_worker_runtime::{LogExporter, OwnedOtlpLogger}; use std::collections::HashMap; @@ -48,9 +48,10 @@ pub(crate) struct DaemonRuntime { /// shape without the caller re-supplying it. #[derive(Debug, Default, Clone)] pub struct DaemonLaunchOptions { - /// Linked-resource names whose binding is a runtime-only secret (a local - /// Postgres password): re-resolved live at every start, never persisted. - pub runtime_only_binding_names: Vec, + /// Linked resources whose binding is a runtime-only secret (a local + /// Postgres password or a local BYO-key AI binding): re-resolved live at + /// every start by resource type, never persisted. + pub runtime_only_bindings: Vec, /// Env var NAMES whose resolved values are deployment secrets (including /// the receiver's `ALIEN_COMMANDS_TOKEN`): delivered to the process but /// stripped from the persisted metadata. The in-memory runtime keeps the @@ -166,15 +167,15 @@ impl LocalWorkerManager { // Re-resolve each secret live (kept out of persisted metadata; see plan_worker_launch). let mut resolved_bindings = Vec::new(); - for name in &options.runtime_only_binding_names { + for binding in &options.runtime_only_bindings { if let Some(entry) = bindings_provider - .resolve_runtime_only_binding_env(name) + .resolve_runtime_only_binding_env(&binding.name, &binding.resource_type) .await .context(ErrorData::Other { - message: format!("Failed to resolve runtime-only binding '{}'", name), + message: format!("Failed to resolve runtime-only binding '{}'", binding.name), })? { - resolved_bindings.push((name.clone(), entry)); + resolved_bindings.push((binding.name.clone(), entry)); } } let (updated_metadata, runtime_env_vars) = Self::plan_worker_launch( @@ -183,7 +184,7 @@ impl LocalWorkerManager { &existing_metadata, None, env_vars, - options.runtime_only_binding_names, + options.runtime_only_bindings, &existing_metadata.runtime_only_env_names.clone(), &resolved_bindings, ); diff --git a/crates/alien-local/src/lib.rs b/crates/alien-local/src/lib.rs index 7d8d85bb1..45206a96b 100644 --- a/crates/alien-local/src/lib.rs +++ b/crates/alien-local/src/lib.rs @@ -81,4 +81,4 @@ pub use postgres_manager::LocalPostgresManager; pub use queue_manager::LocalQueueManager; pub use storage_manager::LocalStorageManager; pub use vault_manager::LocalVaultManager; -pub use worker_manager::LocalWorkerManager; +pub use worker_manager::{LocalWorkerManager, RuntimeOnlyBindingRef}; diff --git a/crates/alien-local/src/local_bindings_provider.rs b/crates/alien-local/src/local_bindings_provider.rs index 2832f1067..3d776e54f 100644 --- a/crates/alien-local/src/local_bindings_provider.rs +++ b/crates/alien-local/src/local_bindings_provider.rs @@ -275,33 +275,84 @@ impl LocalBindingsProvider { } } } + + /// Inherent counterpart of [`BindingsProviderApi::resolve_runtime_only_binding_env`], so + /// controllers holding the concrete provider (the container path) can resolve without + /// importing the trait. The resource type routes to the local secret source. + pub async fn resolve_runtime_only_binding_env( + &self, + binding_name: &str, + resource_type: &str, + ) -> alien_bindings::error::Result>> { + if resource_type == alien_core::Postgres::RESOURCE_TYPE.0 { + // Re-reads the password live from the manager's 0600 metadata. `try_get_binding` + // is `None` for a name absent on recover; external (Remote Access) Postgres is + // rejected upstream on the local platform, so it never reaches here. + return match self.postgres_manager.try_get_binding(binding_name).context( + BindingsErrorData::config_invalid( + binding_name, + "Failed to read the local Postgres metadata", + ), + )? { + Some(binding) => Ok(Some( + alien_core::bindings::serialize_binding_as_env_var(binding_name, &binding) + .context(BindingsErrorData::config_invalid( + binding_name, + "Failed to serialize local Postgres binding", + ))?, + )), + None => Ok(None), + }; + } + if resource_type == alien_core::Ai::RESOURCE_TYPE.0 { + let api_key = std::env::var(alien_core::bindings::AiBinding::LOCAL_API_KEY_ENV) + .ok() + .filter(|key| !key.is_empty()) + .ok_or_else(|| { + AlienError::new(BindingsErrorData::config_invalid( + binding_name, + format!( + "local AI binding needs {} set in the environment", + alien_core::bindings::AiBinding::LOCAL_API_KEY_ENV + ), + )) + })?; + return Ok(Some(local_ai_binding_env(binding_name, api_key)?)); + } + // Only types a controller marked runtime-only reach this resolver; anything else is a + // marking/resolution mismatch, not a missing resource. + Err(AlienError::new(BindingsErrorData::config_invalid( + binding_name, + format!("no local runtime-only secret source for resource type '{resource_type}'"), + ))) + } +} + +/// Builds the full BYO-key AI binding env entry for a linked workload. Pure so the +/// key handling is unit-testable; the caller reads the key from the environment. +fn local_ai_binding_env( + binding_name: &str, + api_key: String, +) -> alien_bindings::error::Result> { + let binding = alien_core::bindings::AiBinding::external( + alien_core::bindings::AiBinding::LOCAL_DEFAULT_PROVIDER, + api_key, + ); + alien_core::bindings::serialize_binding_as_env_var(binding_name, &binding).context( + BindingsErrorData::config_invalid(binding_name, "Failed to serialize local AI binding"), + ) } #[async_trait] impl BindingsProviderApi for LocalBindingsProvider { - /// `try_get_binding` is `None` for any name that isn't a managed local Postgres; the password is - /// read live from the manager's 0600 metadata each call. (External/Remote-Access Postgres is - /// rejected upstream on the local platform, so it never reaches here.) async fn resolve_runtime_only_binding_env( &self, binding_name: &str, + resource_type: &str, ) -> alien_bindings::error::Result>> { - match self - .postgres_manager - .try_get_binding(binding_name) - .context(BindingsErrorData::config_invalid( - binding_name, - "Failed to read the local Postgres metadata", - ))? { - Some(binding) => Ok(Some( - alien_core::bindings::serialize_binding_as_env_var(binding_name, &binding) - .context(BindingsErrorData::config_invalid( - binding_name, - "Failed to serialize local Postgres binding", - ))?, - )), - None => Ok(None), - } + // The inherent method shadows this trait method on the concrete type. + LocalBindingsProvider::resolve_runtime_only_binding_env(self, binding_name, resource_type) + .await } async fn load_storage( @@ -602,3 +653,30 @@ impl BindingsProviderApi for LocalBindingsProvider { })) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_ai_binding_env_carries_the_full_external_binding() { + let env = local_ai_binding_env("llm", "sk-test-key".to_string()) + .expect("AI binding env should serialize"); + let raw = env + .get("ALIEN_LLM_BINDING") + .expect("binding env var should be present"); + let value: serde_json::Value = serde_json::from_str(raw).expect("binding is JSON"); + assert_eq!( + value.get("service").and_then(|v| v.as_str()), + Some("external") + ); + assert_eq!( + value.get("provider").and_then(|v| v.as_str()), + Some("openai") + ); + assert_eq!( + value.get("apiKey").and_then(|v| v.as_str()), + Some("sk-test-key") + ); + } +} diff --git a/crates/alien-local/src/worker_manager.rs b/crates/alien-local/src/worker_manager.rs index 0841c75fe..50977bdcd 100644 --- a/crates/alien-local/src/worker_manager.rs +++ b/crates/alien-local/src/worker_manager.rs @@ -58,6 +58,36 @@ struct WorkerRuntime { metadata: WorkerMetadata, } +/// A linked resource whose binding carries a runtime-only secret (a local Postgres password, a +/// local BYO-key AI binding). The name locates the binding env var; the resource type routes live +/// re-resolution to the right local source. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeOnlyBindingRef { + /// The linked resource's binding name (`ALIEN__BINDING`). + pub name: String, + /// The linked resource's type (e.g. "postgres", "ai"). + pub resource_type: String, +} + +impl RuntimeOnlyBindingRef { + /// Refs for the links whose Local binding carries a runtime-only secret — the single list of + /// types `LocalBindingsProvider::resolve_runtime_only_binding_env` knows how to re-resolve. + pub fn from_links(links: &[alien_core::ResourceRef]) -> Vec { + links + .iter() + .filter(|link| { + link.resource_type() == &alien_core::Postgres::RESOURCE_TYPE + || link.resource_type() == &alien_core::Ai::RESOURCE_TYPE + }) + .map(|link| Self { + name: link.id().to_string(), + resource_type: link.resource_type().to_string(), + }) + .collect() + } +} + /// Persistent metadata for a worker (saved to disk) #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct WorkerMetadata { @@ -74,9 +104,13 @@ pub(crate) struct WorkerMetadata { /// Transport port for the runtime (persisted to enable transparent recovery) #[serde(default)] pub(crate) transport_port: Option, - /// Names of linked resources whose binding is a runtime-only secret (a local Postgres password), - /// persisted so recovery/restart can re-resolve it live; the secret itself is never written here. + /// Linked resources whose binding is a runtime-only secret, persisted so recovery/restart + /// can re-resolve it live by resource type; the secret itself is never written here. #[serde(default)] + pub(crate) runtime_only_bindings: Vec, + /// Names-only form written by older CLIs (no resource type recorded); folded into + /// `runtime_only_bindings` on read, never written back. + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) runtime_only_binding_names: Vec, /// Env var NAMES whose resolved values are deployment secrets; the values /// are stripped from this persisted file (see `plan_worker_launch`) and @@ -90,6 +124,23 @@ pub(crate) struct WorkerMetadata { pub(crate) stop_grace_period_seconds: Option, } +impl WorkerMetadata { + /// Typed runtime-only refs for (re)start, folding legacy names-only entries in as Postgres — + /// the only type that existed when the names-only format was written. + fn runtime_only_binding_refs(&self) -> Vec { + let mut refs = self.runtime_only_bindings.clone(); + for name in &self.runtime_only_binding_names { + if !refs.iter().any(|r| &r.name == name) { + refs.push(RuntimeOnlyBindingRef { + name: name.clone(), + resource_type: alien_core::Postgres::RESOURCE_TYPE.to_string(), + }); + } + } + refs + } +} + impl LocalWorkerManager { /// Creates a new worker manager with shared shutdown signal. /// @@ -261,10 +312,11 @@ impl LocalWorkerManager { info!(worker_id = %metadata.worker_id, "Recovering worker from previous run"); // Restart the worker using metadata + let runtime_only_bindings = metadata.runtime_only_binding_refs(); Self::start_worker_internal( &metadata.worker_id, metadata.env_vars, - metadata.runtime_only_binding_names, + runtime_only_bindings, metadata.runtime_only_env_names, state_dir, workers, @@ -330,10 +382,11 @@ impl LocalWorkerManager { warn!(worker_id = %worker_id, "Auto-restarting worker..."); // Restart using metadata + let runtime_only_bindings = metadata.runtime_only_binding_refs(); if let Err(e) = Self::start_worker_internal( &metadata.worker_id, metadata.env_vars, - metadata.runtime_only_binding_names, + runtime_only_bindings, metadata.runtime_only_env_names, state_dir, workers, @@ -486,13 +539,13 @@ impl LocalWorkerManager { &self, id: &str, env_vars: HashMap, - runtime_only_binding_names: Vec, + runtime_only_bindings: Vec, runtime_only_env_names: Vec, ) -> Result { Self::start_worker_internal( id, env_vars, - runtime_only_binding_names, + runtime_only_bindings, runtime_only_env_names, &self.state_dir, &self.workers, @@ -502,7 +555,7 @@ impl LocalWorkerManager { } /// Builds the worker's persisted metadata and its live process env. The persisted metadata has - /// each named runtime-only binding's key removed — keyed on the names, not on what re-resolved, + /// each marked runtime-only binding's key removed — keyed on the refs, not on what re-resolved, /// so a secret never persists even if live re-resolution returns nothing (e.g. the resource /// vanished between env-build and start) — while the live env keeps the re-resolved secret. Pure /// (no IO) so the "password never persisted" invariant is unit-testable on the artifact we @@ -514,7 +567,7 @@ impl LocalWorkerManager { existing: &WorkerMetadata, transport_port: Option, passed_env: HashMap, - runtime_only_binding_names: Vec, + runtime_only_bindings: Vec, runtime_only_env_names: &[String], resolved: &[(String, HashMap)], ) -> (WorkerMetadata, HashMap) { @@ -528,8 +581,8 @@ impl LocalWorkerManager { { runtime_only_env_names.push(alien_core::ENV_ALIEN_COMMANDS_TOKEN.to_string()); } - for name in &runtime_only_binding_names { - persisted_env.remove(&alien_core::bindings::binding_env_var_name(name)); + for binding in &runtime_only_bindings { + persisted_env.remove(&alien_core::bindings::binding_env_var_name(&binding.name)); } // Resolved deployment secrets (including ALIEN_COMMANDS_TOKEN) never // persist either — same invariant as the binding secrets above. @@ -546,7 +599,8 @@ impl LocalWorkerManager { runtime_command: existing.runtime_command.clone(), working_dir: existing.working_dir.clone(), transport_port, - runtime_only_binding_names, + runtime_only_bindings, + runtime_only_binding_names: Vec::new(), runtime_only_env_names, stop_grace_period_seconds: existing.stop_grace_period_seconds, }; @@ -557,7 +611,7 @@ impl LocalWorkerManager { async fn start_worker_internal( id: &str, env_vars: HashMap, - runtime_only_binding_names: Vec, + runtime_only_bindings: Vec, runtime_only_env_names: Vec, state_dir: &PathBuf, workers: &Arc>>, @@ -627,15 +681,15 @@ impl LocalWorkerManager { // Re-resolve each secret live (kept out of persisted metadata; see plan_worker_launch). let mut resolved_bindings = Vec::new(); - for name in &runtime_only_binding_names { + for binding in &runtime_only_bindings { if let Some(entry) = bindings_provider - .resolve_runtime_only_binding_env(name) + .resolve_runtime_only_binding_env(&binding.name, &binding.resource_type) .await .context(ErrorData::Other { - message: format!("Failed to resolve runtime-only binding '{}'", name), + message: format!("Failed to resolve runtime-only binding '{}'", binding.name), })? { - resolved_bindings.push((name.clone(), entry)); + resolved_bindings.push((binding.name.clone(), entry)); } } let (updated_metadata, runtime_env_vars) = Self::plan_worker_launch( @@ -644,7 +698,7 @@ impl LocalWorkerManager { &existing_metadata, Some(port), env_vars, - runtime_only_binding_names, + runtime_only_bindings, &runtime_only_env_names, &resolved_bindings, ); @@ -1285,7 +1339,8 @@ impl LocalWorkerManager { runtime_command: metadata.runtime_command(), working_dir: metadata.working_dir, transport_port: None, // Will be allocated during start_worker - runtime_only_binding_names: Vec::new(), // Will be set during start_worker + runtime_only_bindings: Vec::new(), // Will be set during start_worker + runtime_only_binding_names: Vec::new(), runtime_only_env_names: Vec::new(), // Will be set during start_daemon stop_grace_period_seconds: None, // Will be set during start_daemon }; @@ -1362,7 +1417,8 @@ impl LocalWorkerManager { runtime_command: metadata.runtime_command(), working_dir: metadata.working_dir, transport_port: None, // Will be allocated during start_worker - runtime_only_binding_names: Vec::new(), // Will be set during start_worker + runtime_only_bindings: Vec::new(), // Will be set during start_worker + runtime_only_binding_names: Vec::new(), runtime_only_env_names: Vec::new(), // Will be set during start_daemon stop_grace_period_seconds: None, // Will be set during start_daemon }; @@ -1506,6 +1562,7 @@ mod tests { runtime_command: vec!["bun".to_string()], working_dir: None, transport_port: None, + runtime_only_bindings: Vec::new(), runtime_only_binding_names: Vec::new(), runtime_only_env_names: Vec::new(), stop_grace_period_seconds: None, @@ -1530,21 +1587,22 @@ mod tests { &existing, Some(3000), base, - vec!["pgdb".to_string()], + vec![RuntimeOnlyBindingRef { + name: "pgdb".to_string(), + resource_type: "postgres".to_string(), + }], &[], &resolved, ); // Persisted metadata: no password, no binding key; the (non-secret) link name stays. let json = serde_json::to_string(&metadata).expect("metadata serializes"); - assert!( - !json.contains("s3cr3t"), - "persisted metadata leaks the password: {json}" - ); + assert!(!json.contains("s3cr3t"), "persisted metadata leaks the password: {json}"); assert!(!metadata.env_vars.contains_key("ALIEN_PGDB_BINDING")); assert!(metadata - .runtime_only_binding_names - .contains(&"pgdb".to_string())); + .runtime_only_bindings + .iter() + .any(|r| r.name == "pgdb" && r.resource_type == "postgres")); // Live process env: the password is delivered to the worker. assert!(live @@ -1562,6 +1620,7 @@ mod tests { runtime_command: vec!["bun".to_string()], working_dir: None, transport_port: None, + runtime_only_bindings: Vec::new(), runtime_only_binding_names: Vec::new(), runtime_only_env_names: Vec::new(), stop_grace_period_seconds: None, @@ -1616,6 +1675,7 @@ mod tests { runtime_command: Vec::new(), working_dir: None, transport_port: None, + runtime_only_bindings: Vec::new(), runtime_only_binding_names: vec!["database".to_string()], runtime_only_env_names: Vec::new(), stop_grace_period_seconds: None, @@ -1644,6 +1704,7 @@ mod tests { runtime_command: Vec::new(), working_dir: None, transport_port: None, + runtime_only_bindings: Vec::new(), runtime_only_binding_names: Vec::new(), runtime_only_env_names: Vec::new(), stop_grace_period_seconds: None, @@ -1676,6 +1737,7 @@ mod tests { runtime_command: Vec::new(), working_dir: None, transport_port: None, + runtime_only_bindings: Vec::new(), runtime_only_binding_names: Vec::new(), runtime_only_env_names: Vec::new(), stop_grace_period_seconds: None, @@ -1693,19 +1755,44 @@ mod tests { &existing, Some(3000), base, - vec!["pgdb".to_string()], + vec![RuntimeOnlyBindingRef { + name: "pgdb".to_string(), + resource_type: "postgres".to_string(), + }], &[], &[], ); let json = serde_json::to_string(&metadata).expect("metadata serializes"); - assert!( - !json.contains("s3cr3t"), - "named binding must be stripped even unresolved: {json}" - ); + assert!(!json.contains("s3cr3t"), "named binding must be stripped even unresolved: {json}"); assert!(!metadata.env_vars.contains_key("ALIEN_PGDB_BINDING")); assert_eq!(metadata.env_vars.get("FOO"), Some(&"bar".to_string())); } + /// Metadata written by a names-only CLI (the Postgres-only era) still recovers: the legacy + /// names fold into typed refs as Postgres, and typed entries win over a same-name legacy one. + #[test] + fn legacy_names_only_metadata_recovers_as_postgres_refs() { + let metadata: WorkerMetadata = serde_json::from_str( + r#"{ + "worker_id": "w", + "extracted_path": "/w", + "env_vars": {}, + "runtime_command": [], + "working_dir": null, + "runtime_only_binding_names": ["db"] + }"#, + ) + .expect("legacy metadata deserializes"); + let refs = metadata.runtime_only_binding_refs(); + assert_eq!( + refs, + vec![RuntimeOnlyBindingRef { + name: "db".to_string(), + resource_type: "postgres".to_string(), + }] + ); + } + fn paths(names: &[&str]) -> Vec { names .iter() diff --git a/crates/alien-permissions/permission-sets/ai/heartbeat.jsonc b/crates/alien-permissions/permission-sets/ai/heartbeat.jsonc new file mode 100644 index 000000000..f4a1bc7b1 --- /dev/null +++ b/crates/alien-permissions/permission-sets/ai/heartbeat.jsonc @@ -0,0 +1,53 @@ +{ + "id": "ai/heartbeat", + "description": "Allows readonly monitoring and health checks for AI gateway resources", + "platforms": { + "aws": [ + { + "label": "check-bedrock-availability", + "description": "Read Bedrock foundation model availability needed for application health checks.", + "grant": { + "actions": ["bedrock:ListFoundationModels"] + }, + "binding": { + "stack": { + "resources": ["arn:aws:bedrock:*::foundation-model/*"] + }, + "resource": { + "resources": ["arn:aws:bedrock:*::foundation-model/*"] + } + } + } + ], + "gcp": [ + { + "label": "check-vertex-ai-health", + "description": "Read Vertex AI model and endpoint metadata needed for application health checks.", + "grant": { + "permissions": ["aiplatform.models.list", "aiplatform.endpoints.list"] + }, + "binding": { + "stack": { "scope": "projects/${projectName}" }, + "resource": { "scope": "projects/${projectName}" } + } + } + ], + "azure": [ + { + "label": "check-cognitive-services-health", + "description": "Read Azure Cognitive Services account metadata needed for application health checks.", + "grant": { + "actions": ["Microsoft.CognitiveServices/accounts/read"] + }, + "binding": { + "stack": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}" + }, + "resource": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.CognitiveServices/accounts/${resourceName}" + } + } + } + ] + } +} diff --git a/crates/alien-permissions/permission-sets/ai/invoke.jsonc b/crates/alien-permissions/permission-sets/ai/invoke.jsonc new file mode 100644 index 000000000..3360bcab5 --- /dev/null +++ b/crates/alien-permissions/permission-sets/ai/invoke.jsonc @@ -0,0 +1,79 @@ +{ + "id": "ai/invoke", + "description": "Allows invoking AI models for inference (no deployment writes)", + "platforms": { + "aws": [ + { + "grant": { + "actions": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + "bedrock-mantle:CreateInference" + ] + }, + // AWS Bedrock foundation models are not per-account or per-stack resources; + // they are a global namespace. Claude models are invoked via cross-region + // inference profiles (us.anthropic.* and the region-agnostic global.anthropic.*), + // which live in the invoking account and must be allowed alongside the + // foundation-model ARN in the same statement (AWS docs: "Prerequisites for + // inference profiles"). + // The bedrock-mantle endpoint (native Anthropic Messages + OpenAI Responses) + // authorizes CreateInference against a project resource. The gateway sends no + // project id, so requests use the account's system default project; scope the + // grant to that one project (project/default) rather than project/*, which + // would also cover any user-created bedrock-mantle project. + "binding": { + "stack": { + "resources": [ + "arn:aws:bedrock:*::foundation-model/*", + "arn:aws:bedrock:*:${awsAccountId}:inference-profile/*", + "arn:aws:bedrock-mantle:*:${awsAccountId}:project/default" + ] + }, + "resource": { + "resources": [ + "arn:aws:bedrock:*::foundation-model/*", + "arn:aws:bedrock:*:${awsAccountId}:inference-profile/*", + "arn:aws:bedrock-mantle:*:${awsAccountId}:project/default" + ] + } + } + } + ], + "gcp": [ + { + "grant": { + // Custom role containing only prediction permissions. roles/aiplatform.user + // includes control-plane write actions (endpoints.deploy/undeploy, models.upload/delete) + // and sensitive-data reads (datasets.export, featurestores.readFeatures, etc.) that + // a workload must never hold. A custom role with the two predict permissions is the + // minimum required for Vertex AI inference. + "permissions": ["aiplatform.endpoints.predict", "aiplatform.endpoints.explain"] + }, + // Vertex AI is a project-level service; IAM bindings are scoped to the project. + "binding": { + "stack": { "scope": "projects/${projectName}" }, + "resource": { "scope": "projects/${projectName}" } + } + } + ], + "azure": [ + { + "grant": { + // "Cognitive Services OpenAI User" is the least-privilege data-plane role for the + // OpenAI-compatible inference endpoint: it grants inference only, not deployment + // management or account configuration. + "predefinedRoles": ["Cognitive Services OpenAI User"] + }, + "binding": { + "stack": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}" + }, + "resource": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.CognitiveServices/accounts/${resourceName}" + } + } + } + ] + } +} diff --git a/crates/alien-permissions/permission-sets/ai/management.jsonc b/crates/alien-permissions/permission-sets/ai/management.jsonc new file mode 100644 index 000000000..6318c2635 --- /dev/null +++ b/crates/alien-permissions/permission-sets/ai/management.jsonc @@ -0,0 +1,49 @@ +{ + "id": "ai/management", + "description": "Allows reading AI gateway account configuration and listing available models", + "platforms": { + "aws": [ + { + "grant": { + "actions": ["bedrock:ListFoundationModels"] + }, + "binding": { + "stack": { + "resources": ["arn:aws:bedrock:*::foundation-model/*"] + }, + "resource": { + "resources": ["arn:aws:bedrock:*::foundation-model/*"] + } + } + } + ], + "gcp": [ + { + "grant": { + "permissions": ["aiplatform.models.list", "aiplatform.endpoints.list"] + }, + "binding": { + "stack": { "scope": "projects/${projectName}" }, + "resource": { "scope": "projects/${projectName}" } + } + } + ], + "azure": [ + { + "grant": { + // Read-only management: inspect the account configuration. Deploying the + // predefined model set is a provision-time action (ai/provision), not management. + "actions": ["Microsoft.CognitiveServices/accounts/read"] + }, + "binding": { + "stack": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}" + }, + "resource": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.CognitiveServices/accounts/${resourceName}" + } + } + } + ] + } +} diff --git a/crates/alien-permissions/permission-sets/ai/provision.jsonc b/crates/alien-permissions/permission-sets/ai/provision.jsonc new file mode 100644 index 000000000..341759534 --- /dev/null +++ b/crates/alien-permissions/permission-sets/ai/provision.jsonc @@ -0,0 +1,69 @@ +{ + "id": "ai/provision", + "description": "Allows provisioning and destroying AI gateway resources", + "platforms": { + "aws": [ + { + "grant": { + // Bedrock access is grantless at the infrastructure level; provisioning + // consists of attaching/detaching the bedrock-invoke policy on the + // workload IAM role created for this resource. + "actions": [ + "iam:PutRolePolicy", + "iam:GetRolePolicy", + "iam:DeleteRolePolicy", + "iam:ListRolePolicies" + ] + }, + "binding": { + "stack": { + "resources": ["arn:aws:iam::${awsAccountId}:role/${stackPrefix}-*"] + }, + "resource": { + "resources": ["arn:aws:iam::${awsAccountId}:role/${stackPrefix}-${resourceName}"] + } + } + } + ], + "gcp": [ + { + "grant": { + "permissions": [ + "serviceusage.services.enable", + "resourcemanager.projects.getIamPolicy", + "resourcemanager.projects.setIamPolicy" + ] + }, + "binding": { + "stack": { "scope": "projects/${projectName}" }, + "resource": { "scope": "projects/${projectName}" } + } + } + ], + "azure": [ + { + "grant": { + "actions": [ + "Microsoft.CognitiveServices/accounts/write", + "Microsoft.CognitiveServices/accounts/delete", + "Microsoft.CognitiveServices/accounts/read", + // Deploy and inspect the predefined model set at provision time. + "Microsoft.CognitiveServices/accounts/deployments/write", + "Microsoft.CognitiveServices/accounts/deployments/read", + "Microsoft.Authorization/roleAssignments/write", + "Microsoft.Authorization/roleAssignments/delete", + "Microsoft.Authorization/roleAssignments/read" + ] + }, + "binding": { + "stack": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}" + }, + "resource": { + "scope": "/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.CognitiveServices/accounts/${resourceName}" + } + } + } + ] + } +} diff --git a/crates/alien-permissions/src/generators/azure_runtime.rs b/crates/alien-permissions/src/generators/azure_runtime.rs index 8a2129782..a15dab1f9 100644 --- a/crates/alien-permissions/src/generators/azure_runtime.rs +++ b/crates/alien-permissions/src/generators/azure_runtime.rs @@ -582,6 +582,7 @@ pub fn azure_predefined_role_id(role_name: &str) -> Option<&'static str> { "AcrPush" => Some("8311e382-0749-4cb8-b61a-304f252e45ec"), "Azure Service Bus Data Receiver" => Some("4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0"), "Azure Service Bus Data Sender" => Some("69a216fc-b8fb-44d8-bc22-1f3c2cd27a39"), + "Cognitive Services OpenAI User" => Some("5e0bd9bd-7b93-4f28-af87-19fc36ad61bd"), "Key Vault Contributor" => Some("f25e0fa2-a7c8-4377-a976-54943a77a395"), "Key Vault Secrets User" => Some("4633458b-17de-408a-b874-0445c86b69e6"), "Managed Identity Contributor" => Some("e40ec5ca-96e0-45a2-b4ff-59039f2c2b59"), diff --git a/crates/alien-permissions/tests/aws_abac_validation.rs b/crates/alien-permissions/tests/aws_abac_validation.rs index 0728f320e..ba83fd575 100644 --- a/crates/alien-permissions/tests/aws_abac_validation.rs +++ b/crates/alien-permissions/tests/aws_abac_validation.rs @@ -296,7 +296,32 @@ fn aws_resource_arns_are_stack_or_resource_scoped_unless_documented_external() { fn documented_external_resource_scope(resource: &str) -> bool { // Runtime compute pulls images from the manager-owned artifact registry. Target-account // isolation is enforced by the repository resource policy that grants this role access. - resource == "arn:aws:ecr:*:${managingAccountId}:repository/*" + if resource == "arn:aws:ecr:*:${managingAccountId}:repository/*" { + return true; + } + + // AWS Bedrock foundation models are a global AWS namespace owned by AWS, not the + // customer account. There is no per-stack or per-account ARN to scope to; the + // wildcard path is required and does not widen customer data access. + if resource == "arn:aws:bedrock:*::foundation-model/*" { + return true; + } + + // AWS Bedrock cross-region inference profiles are system-defined by AWS + // (us.anthropic.*, etc.) and not per-stack resources. They live in the + // invoking account, so the ARN pins the account and wildcards only region + // and profile name. See AWS docs: "Prerequisites for inference profiles" + // (bedrock:InvokeModel* on both foundation-model/* and inference-profile/*). + if resource == "arn:aws:bedrock:*:${awsAccountId}:inference-profile/*" { + return true; + } + + // The bedrock-mantle endpoint (native Anthropic Messages + OpenAI Responses) + // authorizes inference against the account's system-defined default project + // (`project/default`), which, like inference profiles, exists per account + // rather than per stack. The grant is pinned to that one project (not + // `project/*`) since the gateway only ever uses the default project. + resource == "arn:aws:bedrock-mantle:*:${awsAccountId}:project/default" } fn documented_ses_domain_identity_scope(resource: &str) -> bool { diff --git a/crates/alien-permissions/tests/permission_set_validation.rs b/crates/alien-permissions/tests/permission_set_validation.rs index bcee96022..dad8b2453 100644 --- a/crates/alien-permissions/tests/permission_set_validation.rs +++ b/crates/alien-permissions/tests/permission_set_validation.rs @@ -341,10 +341,15 @@ fn validate_aws_actions( } } None => { - invalid_actions.push(format!( - "{} - service '{}' not found", - action, action_service - )); + // The pinned dataset can be missing a whole service, not just one + // of its actions, when the service is newer than the snapshot. A + // documented gap is still a real action. + if !is_known_aws_dataset_gap(action) { + invalid_actions.push(format!( + "{} - service '{}' not found", + action, action_service + )); + } } } } else { @@ -371,10 +376,18 @@ fn validate_aws_actions( } fn is_known_aws_dataset_gap(action: &str) -> bool { - // API Gateway v2 authorizes tagged CreateStage calls through TagResource. - // The pinned IAM dataset still models API Gateway mostly through method-like - // actions such as POST/PATCH and does not list this action. - matches!(action, "apigateway:TagResource") + matches!( + action, + // API Gateway v2 authorizes tagged CreateStage calls through TagResource. + // The pinned IAM dataset still models API Gateway mostly through method-like + // actions such as POST/PATCH and does not list this action. + | "apigateway:TagResource" + // bedrock-mantle (the OpenAI/Anthropic-compatible inference endpoint) is newer + // than the pinned dataset snapshot, which carries no entry for the service at + // all. The action is real: AWS's own AmazonBedrockMantleInferenceAccess managed + // policy grants bedrock-mantle:CreateInference. + | "bedrock-mantle:CreateInference" + ) } /// Validate GCP permissions in a permission set diff --git a/crates/alien-permissions/tests/registry_tests.rs b/crates/alien-permissions/tests/registry_tests.rs index 615f3d7b1..43e31b21e 100644 --- a/crates/alien-permissions/tests/registry_tests.rs +++ b/crates/alien-permissions/tests/registry_tests.rs @@ -1,5 +1,203 @@ use alien_permissions::{get_permission_set, has_permission_set, list_permission_set_ids}; +#[test] +fn test_ai_permission_sets_resolve_via_registry() { + assert!(has_permission_set("ai/provision")); + assert!(has_permission_set("ai/management")); + assert!(has_permission_set("ai/heartbeat")); + assert!(has_permission_set("ai/invoke")); + + let ids = list_permission_set_ids(); + assert!(ids.contains(&"ai/provision")); + assert!(ids.contains(&"ai/management")); + assert!(ids.contains(&"ai/heartbeat")); + assert!(ids.contains(&"ai/invoke")); +} + +#[test] +fn test_ai_invoke_is_inference_only() { + let invoke = get_permission_set("ai/invoke").expect("ai/invoke must resolve"); + + // AWS: inference actions only, enforced as an explicit allowlist. A substring + // denylist ("contains Create/Delete/Deploy") both over- and under-fires: it + // rejects `bedrock-mantle:CreateInference`, which IS an inference call (it + // creates an inference — it runs the model), while still passing any + // control-plane action whose name happens to lack those words. Adding an entry + // here is deliberate: it must be a data-plane inference call, never a + // deployment or other control-plane write, which belong in ai/provision. + const AWS_INFERENCE_ACTIONS: &[&str] = &[ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + "bedrock-mantle:CreateInference", + ]; + if let Some(aws) = &invoke.platforms.aws { + for entry in aws { + if let Some(actions) = &entry.grant.actions { + for action in actions { + assert!( + AWS_INFERENCE_ACTIONS.contains(&action.as_str()), + "ai/invoke AWS grant must contain only inference actions; found \ + {action}, which is not in {AWS_INFERENCE_ACTIONS:?}. If it is a \ + data-plane inference call, add it deliberately; deployment and \ + control-plane actions belong in ai/provision." + ); + } + } + } + } + + // GCP: must use a custom role (permissions list), never a predefined role. + // roles/aiplatform.user includes control-plane writes and sensitive-data reads + // that a workload must never hold. + if let Some(gcp) = &invoke.platforms.gcp { + for (i, entry) in gcp.iter().enumerate() { + assert!( + entry.grant.predefined_roles.is_none(), + "ai/invoke GCP entry {i} must not use predefinedRoles (grants over-broad control-plane access); use a permissions list instead" + ); + let permissions = entry.grant.permissions.as_ref().unwrap_or_else(|| { + panic!("ai/invoke GCP entry {i} must have a permissions list") + }); + for perm in permissions { + // No control-plane write actions. + assert!( + !perm.contains("deploy") && !perm.contains("upload") && !perm.contains("delete"), + "ai/invoke GCP grant must not contain control-plane write permissions, found: {perm}" + ); + // No sensitive-data read actions. + assert!( + !perm.starts_with("datasets.") + && !perm.contains("featurestores") + && !perm.contains("ragCorpora") + && !perm.contains("sessions"), + "ai/invoke GCP grant must not contain sensitive-data read permissions, found: {perm}" + ); + } + } + } + + // Azure: must NOT grant deployments/write (the key deploy-on-demand invariant) + if let Some(azure) = &invoke.platforms.azure { + for entry in azure { + if let Some(actions) = &entry.grant.actions { + for action in actions { + assert_ne!( + action, + "Microsoft.CognitiveServices/accounts/deployments/write", + "ai/invoke must not grant deployments/write; that belongs in ai/provision" + ); + } + } + // predefinedRoles on invoke must not be management-class roles + if let Some(roles) = &entry.grant.predefined_roles { + for role in roles { + assert_ne!( + role, "Contributor", + "ai/invoke must not use Contributor role" + ); + assert_ne!(role, "Owner", "ai/invoke must not use Owner role"); + } + } + } + } +} + +#[test] +fn test_ai_provision_has_deployment_writes() { + // The predefined model set is deployed at provision time, so deployments/{write,read} + // live in ai/provision (control plane), not in ai/management or ai/invoke. + let provision = get_permission_set("ai/provision").expect("ai/provision must resolve"); + let azure = provision + .platforms + .azure + .as_ref() + .expect("ai/provision must have Azure platform"); + let has_write = azure.iter().any(|entry| { + entry + .grant + .actions + .as_ref() + .map(|actions| { + actions.contains( + &"Microsoft.CognitiveServices/accounts/deployments/write".to_string(), + ) + }) + .unwrap_or(false) + }); + assert!( + has_write, + "ai/provision Azure must grant deployments/write for the predefined model set" + ); + + // ai/management is read-only metadata; deployment writes live in ai/provision. + let management = get_permission_set("ai/management").expect("ai/management must resolve"); + let mgmt_azure = management + .platforms + .azure + .as_ref() + .expect("ai/management must have Azure platform"); + let mgmt_has_write = mgmt_azure.iter().any(|entry| { + entry + .grant + .actions + .as_ref() + .map(|actions| { + actions.contains( + &"Microsoft.CognitiveServices/accounts/deployments/write".to_string(), + ) + }) + .unwrap_or(false) + }); + assert!( + !mgmt_has_write, + "ai/management must not grant deployments/write" + ); +} + +#[test] +fn test_ai_invoke_uses_openai_user_role() { + let invoke = get_permission_set("ai/invoke").expect("ai/invoke must resolve"); + let azure = invoke + .platforms + .azure + .as_ref() + .expect("ai/invoke must have Azure platform"); + let uses_openai_user = azure.iter().any(|entry| { + entry + .grant + .predefined_roles + .as_ref() + .map(|roles| roles.contains(&"Cognitive Services OpenAI User".to_string())) + .unwrap_or(false) + }); + assert!( + uses_openai_user, + "ai/invoke Azure must use the least-privilege 'Cognitive Services OpenAI User' role" + ); +} + +#[test] +fn test_openai_user_role_id_resolves() { + use alien_permissions::generators::azure_runtime::azure_predefined_role_id; + assert_eq!( + azure_predefined_role_id("Cognitive Services OpenAI User"), + Some("5e0bd9bd-7b93-4f28-af87-19fc36ad61bd"), + "the OpenAI-User role GUID must be registered" + ); +} + +#[test] +fn test_ai_permission_sets_have_all_platforms() { + for id in ["ai/provision", "ai/management", "ai/heartbeat", "ai/invoke"] { + let perm_set = get_permission_set(id).unwrap_or_else(|| panic!("{id} must resolve")); + assert_eq!(perm_set.id, id); + assert!(!perm_set.description.is_empty(), "{id} must have a description"); + assert!(perm_set.platforms.aws.is_some(), "{id} must have AWS platform"); + assert!(perm_set.platforms.gcp.is_some(), "{id} must have GCP platform"); + assert!(perm_set.platforms.azure.is_some(), "{id} must have Azure platform"); + } +} + #[test] fn test_registry_basic_functionality() { // Test that the registry contains expected permission sets diff --git a/crates/alien-preflights/src/compile_time/allowed_user_resources.rs b/crates/alien-preflights/src/compile_time/allowed_user_resources.rs index bd7af09fd..9d96c836f 100644 --- a/crates/alien-preflights/src/compile_time/allowed_user_resources.rs +++ b/crates/alien-preflights/src/compile_time/allowed_user_resources.rs @@ -39,6 +39,7 @@ impl CompileTimeCheck for AllowedUserResourcesCheck { // by the ComputeClusterMutation when not declared. "compute-cluster", "postgres", + "ai", "email", "experimental/aws-opensearch", ]); diff --git a/crates/alien-terraform/src/built_ins.rs b/crates/alien-terraform/src/built_ins.rs index df9650be1..7655670b7 100644 --- a/crates/alien-terraform/src/built_ins.rs +++ b/crates/alien-terraform/src/built_ins.rs @@ -5,9 +5,9 @@ use crate::registry::TfRegistry; use alien_core::{ - ArtifactRegistry, AzureContainerAppsEnvironment, AzureResourceGroup, AzureServiceBusNamespace, - AzureStorageAccount, Build, KubernetesCluster, Kv, Network, Platform, Queue, - RemoteStackManagement, ServiceAccount, ServiceActivation, Storage, Vault, Worker, + Ai, ArtifactRegistry, AzureContainerAppsEnvironment, AzureResourceGroup, + AzureServiceBusNamespace, AzureStorageAccount, Build, KubernetesCluster, Kv, Network, Platform, + Queue, RemoteStackManagement, ServiceAccount, ServiceActivation, Storage, Vault, Worker, }; pub(crate) fn register_all(registry: &mut TfRegistry) { @@ -19,6 +19,7 @@ pub(crate) fn register_all(registry: &mut TfRegistry) { fn register_aws(registry: &mut TfRegistry) { use crate::emitters::aws; let p = Platform::Aws; + registry.register(Ai::RESOURCE_TYPE, p, aws::AwsAiEmitter); registry.register(Storage::RESOURCE_TYPE, p, aws::AwsStorageEmitter); registry.register(Kv::RESOURCE_TYPE, p, aws::AwsKvEmitter); registry.register(Queue::RESOURCE_TYPE, p, aws::AwsQueueEmitter); @@ -51,6 +52,7 @@ fn register_aws(registry: &mut TfRegistry) { fn register_gcp(registry: &mut TfRegistry) { use crate::emitters::gcp; let p = Platform::Gcp; + registry.register(Ai::RESOURCE_TYPE, p, gcp::GcpAiEmitter); registry.register(Storage::RESOURCE_TYPE, p, gcp::GcpStorageEmitter); registry.register(Kv::RESOURCE_TYPE, p, gcp::GcpKvEmitter); registry.register(Queue::RESOURCE_TYPE, p, gcp::GcpQueueEmitter); @@ -90,6 +92,7 @@ fn register_azure(registry: &mut TfRegistry) { let p = Platform::Azure; // Main resources — one emitter per Alien resource type. + registry.register(Ai::RESOURCE_TYPE, p, azure::AzureAiEmitter); registry.register(Storage::RESOURCE_TYPE, p, azure::AzureStorageEmitter); registry.register(Kv::RESOURCE_TYPE, p, azure::AzureKvEmitter); registry.register(Queue::RESOURCE_TYPE, p, azure::AzureQueueEmitter); diff --git a/crates/alien-terraform/src/emitters/aws/ai.rs b/crates/alien-terraform/src/emitters/aws/ai.rs new file mode 100644 index 000000000..78888111c --- /dev/null +++ b/crates/alien-terraform/src/emitters/aws/ai.rs @@ -0,0 +1,119 @@ +//! AWS AI — Bedrock inference gateway. +//! +//! AWS Bedrock is a regional, account-scoped service with no per-stack +//! cloud resource to provision. The emitter returns an empty fragment for +//! the resource itself and emits any resource-scoped `aws_iam_role_policy` +//! blocks for permission profiles that reference `ai/invoke` on this +//! resource. The region is carried in the import ref so the controller can +//! reconstruct the Bedrock endpoint without a cloud round-trip. +//! +//! Stack-level permissions flow through `AwsServiceAccountEmitter` via +//! `stack_permission_sets`; resource-scoped grants are emitted here. + +use crate::{ + emitter::{TfEmitter, TfFragment}, + emitters::aws::helpers::{ + aws_terraform_permission_context, downcast, emit_iam_role_policy_for_target_with_label, + iam_policy_name_sanitize, required_label, + }, + expr, +}; +use alien_core::{ + import::EmitContext, Ai, PermissionProfile, PermissionSetReference, Result, ServiceAccount, +}; +use alien_permissions::BindingTarget; +use hcl::expr::Expression; + +#[derive(Debug, Clone, Copy, Default)] +pub struct AwsAiEmitter; + +impl TfEmitter for AwsAiEmitter { + fn emit(&self, ctx: &EmitContext<'_>) -> Result { + let ai = downcast::(ctx, Ai::RESOURCE_TYPE)?; + let mut fragment = TfFragment::empty(); + + let context = aws_terraform_permission_context() + .with_resource_name(format!("${{local.resource_prefix}}-{}", ai.id())); + + for (owner_label, permission_set_refs) in ai_permission_owners(ctx) { + for (idx, permission_set_ref) in permission_set_refs.iter().enumerate() { + if let Some(permission_set) = permission_set_ref + .resolve(|name| alien_permissions::get_permission_set(name).cloned()) + { + let ai_label_segment = sanitize_label_segment(ai.id()); + emit_iam_role_policy_for_target_with_label( + &mut fragment, + &owner_label, + &permission_set, + &format!("{owner_label}_ai_{ai_label_segment}_set_{idx}"), + &format!( + "access-{}-{}", + ai.id(), + iam_policy_name_sanitize(&permission_set.id) + ), + &context, + BindingTarget::Resource, + )?; + } + } + } + + Ok(fragment) + } + + fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { + let _ = downcast::(ctx, Ai::RESOURCE_TYPE)?; + let _ = required_label(ctx)?; + Ok(expr::object([("region", expr::raw("data.aws_region.current.region"))])) + } +} + +fn ai_permission_owners(ctx: &EmitContext<'_>) -> Vec<(String, Vec)> { + let mut owners = Vec::new(); + for (profile_name, profile) in ctx.stack.permission_profiles() { + let refs = ai_permission_refs(profile, ctx.resource_id); + if refs.is_empty() { + continue; + } + let service_account_id = format!("{profile_name}-sa"); + if let Some(label) = service_account_label(ctx, &service_account_id) { + owners.push((label.to_string(), refs)); + } + } + owners +} + +fn ai_permission_refs(profile: &PermissionProfile, resource_id: &str) -> Vec { + let mut refs = Vec::new(); + let mut seen_ids = std::collections::HashSet::new(); + if let Some(resource_refs) = profile.0.get(resource_id) { + for permission_ref in resource_refs { + if seen_ids.insert(permission_ref.id().to_string()) { + refs.push(permission_ref.clone()); + } + } + } + refs +} + +fn service_account_label<'a>(ctx: &'a EmitContext<'_>, service_account_id: &str) -> Option<&'a str> { + let (_id, entry) = ctx + .stack + .resources() + .find(|(id, _entry)| id.as_str() == service_account_id)?; + entry.config.downcast_ref::()?; + ctx.name_for(service_account_id) +} + +fn sanitize_label_segment(input: &str) -> String { + input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '_' + } + }) + .collect() +} diff --git a/crates/alien-terraform/src/emitters/aws/mod.rs b/crates/alien-terraform/src/emitters/aws/mod.rs index a76076e60..a03691de1 100644 --- a/crates/alien-terraform/src/emitters/aws/mod.rs +++ b/crates/alien-terraform/src/emitters/aws/mod.rs @@ -4,6 +4,7 @@ //! returns `hcl::Block` / `hcl::Expression` directly (no intermediate IR). //! Shared helpers live in [`helpers`]. +pub mod ai; pub mod artifact_registry; pub mod build; pub mod helpers; @@ -16,6 +17,7 @@ pub mod storage; pub mod vault; pub mod worker; +pub use ai::AwsAiEmitter; pub use artifact_registry::AwsArtifactRegistryEmitter; pub use build::AwsBuildEmitter; pub use kv::AwsKvEmitter; diff --git a/crates/alien-terraform/src/emitters/azure/ai.rs b/crates/alien-terraform/src/emitters/azure/ai.rs new file mode 100644 index 000000000..761cb1edf --- /dev/null +++ b/crates/alien-terraform/src/emitters/azure/ai.rs @@ -0,0 +1,233 @@ +//! Azure AI — Azure AIServices (Cognitive Services) account. +//! +//! Mirrors `AzureAiController`: one `azurerm_cognitive_account` per `Ai` +//! resource, configured as `kind = "AIServices"` with SKU `S0` and a +//! `custom_subdomain_name` derived from the stack resource prefix and the +//! resource id. +//! +//! Resource-scoped role assignments (e.g. `Cognitive Services OpenAI User` for +//! `ai/invoke`) are emitted directly by this emitter, scoped to the cognitive +//! account, for each permission profile that references `ai/invoke` on this +//! resource. Stack-level permissions flow through `AzureServiceAccountEmitter` +//! via `stack_permission_sets`. + +use crate::{ + block::{attr, block, nested, resource_block}, + emitter::{TfEmitter, TfFragment}, + emitters::azure::helpers::{ + downcast, permission_context, required_label, resource_prefix_template, + service_account_principal_id, tags, + }, + expr, +}; +use alien_core::{import::EmitContext, Ai, ErrorData, PermissionSetReference, Result}; +use alien_error::{AlienError, Context}; +use alien_permissions::{ + generators::{AzureRoleDefinitionRef, AzureRuntimePermissionsGenerator}, + BindingTarget, +}; +use hcl::expr::Expression; + +#[derive(Debug, Clone, Copy, Default)] +pub struct AzureAiEmitter; + +impl TfEmitter for AzureAiEmitter { + fn emit(&self, ctx: &EmitContext<'_>) -> Result { + let ai = downcast::(ctx, Ai::RESOURCE_TYPE)?; + let label = required_label(ctx)?; + let mut fragment = TfFragment::default(); + + fragment.resource_blocks.push(resource_block( + "azurerm_cognitive_account", + label, + [ + attr("name", resource_prefix_template(ai.id())), + attr( + "resource_group_name", + expr::raw("var.azure_resource_group_name"), + ), + attr("location", expr::raw("var.azure_location")), + attr("kind", Expression::String("AIServices".to_string())), + attr("sku_name", Expression::String("S0".to_string())), + attr("custom_subdomain_name", resource_prefix_template(ai.id())), + attr("tags", tags(ctx, "ai")), + ], + )); + + // One deployment per curated model, mirroring AzureAiController's + // DeployingModels state. capacity = 1 matches the controller's + // DEFAULT_DEPLOYMENT_CAPACITY so the Frozen (Terraform) and Live + // (controller) paths provision the same throughput. + for (deployment, model, version) in alien_core::ai_catalog::azure_deployments() { + fragment.resource_blocks.push(resource_block( + "azurerm_cognitive_deployment", + // The Azure deployment name keeps dots (e.g. gpt-4.1); the Terraform + // resource label cannot, so sanitize it. + &format!("{label}_{}", sanitize_deployment_label(deployment)), + [ + attr("name", Expression::String(deployment.to_string())), + attr( + "cognitive_account_id", + expr::traversal(["azurerm_cognitive_account", label, "id"]), + ), + nested(block( + "model", + [ + attr("format", Expression::String("OpenAI".to_string())), + attr("name", Expression::String(model.to_string())), + attr("version", Expression::String(version.to_string())), + ], + )), + nested(block( + "sku", + [ + attr("name", Expression::String("GlobalStandard".to_string())), + attr("capacity", expr::raw("1")), + ], + )), + ], + )); + } + + emit_ai_permissions(ctx, label, &mut fragment)?; + + Ok(fragment) + } + + fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { + let _ = downcast::(ctx, Ai::RESOURCE_TYPE)?; + let label = required_label(ctx)?; + Ok(expr::object([ + ("subscriptionId", expr::raw("var.azure_subscription_id")), + ("resourceGroup", expr::raw("var.azure_resource_group_name")), + ( + "accountName", + expr::traversal(["azurerm_cognitive_account", label, "name"]), + ), + ( + "endpoint", + expr::traversal(["azurerm_cognitive_account", label, "endpoint"]), + ), + ("location", expr::raw("var.azure_location")), + ])) + } +} + +/// Emit `azurerm_role_assignment` blocks for permission profiles that reference +/// `ai/invoke` (or any `ai/`-prefixed set) scoped to this resource. +fn emit_ai_permissions( + ctx: &EmitContext<'_>, + ai_label: &str, + fragment: &mut TfFragment, +) -> Result<()> { + for (profile_name, profile) in ctx.stack.permission_profiles() { + let Some(principal_id_expr) = service_account_principal_id(ctx, profile_name) else { + continue; + }; + + let refs: Vec<&PermissionSetReference> = profile + .0 + .get(ctx.resource_id) + .map(|refs| refs.iter().collect()) + .unwrap_or_default(); + + for permission_set_ref in refs { + let Some(permission_set) = permission_set_ref + .resolve(|name| alien_permissions::get_permission_set(name).cloned()) + else { + continue; + }; + if permission_set.platforms.azure.is_none() { + continue; + } + + let generator = AzureRuntimePermissionsGenerator::new(); + let perm_context = permission_context(ai_label).with_resource_name( + format!("${{local.resource_prefix}}-{}", ctx.resource_id), + ); + let grant_plan = generator + .generate_grant_plan(&permission_set, BindingTarget::Resource, &perm_context) + .context(ErrorData::GenericError { + message: format!( + "failed to generate Azure AI grants for '{}'", + permission_set.id + ), + })?; + + for (binding_index, binding) in grant_plan.bindings.iter().enumerate() { + let role_definition_id = match &binding.role_definition { + AzureRoleDefinitionRef::Predefined { role_definition_id } => { + expr::template(role_definition_id.clone()) + } + AzureRoleDefinitionRef::Custom { key } => { + // Custom role definitions for resource-scoped AI grants are + // emitted by emit_azure_setup_resource_role_definitions. + let index = grant_plan + .custom_roles + .iter() + .position(|role| role.key == *key) + .ok_or_else(|| AlienError::new(ErrorData::GenericError { + message: format!( + "custom role '{key}' not found in grant plan for Azure AI resource '{}'", + ctx.resource_id + ), + }))?; + let role_label = crate::emitters::azure::helpers::setup_execution_role_label( + profile_name, + &binding.role_name, + index, + ); + expr::traversal([ + "azurerm_role_definition", + role_label.as_str(), + "role_definition_resource_id", + ]) + } + }; + + let role_label = sanitize_role_label(&binding.role_name); + fragment.resource_blocks.push(resource_block( + "azurerm_role_assignment", + &format!("{ai_label}_{role_label}_{profile_name}_assignment_{binding_index}"), + [ + attr( + "name", + expr::raw(&format!( + "uuidv5(\"oid\", \"deployment:azure:ai-role-assign:${{azurerm_cognitive_account.{ai_label}.id}}:{role_label}:{profile_name}:{binding_index}\")" + )), + ), + attr( + "scope", + expr::traversal(["azurerm_cognitive_account", ai_label, "id"]), + ), + attr("role_definition_id", role_definition_id), + attr("principal_id", principal_id_expr.clone()), + ], + )); + } + } + } + + Ok(()) +} + +/// Turn a model deployment name into a valid Terraform resource label (letters, +/// digits, underscores, dashes): `gpt-4.1` becomes `gpt-4_1`. +fn sanitize_deployment_label(name: &str) -> String { + name.chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }) + .collect() +} + +fn sanitize_role_label(input: &str) -> String { + input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '_' + } + }) + .collect() +} diff --git a/crates/alien-terraform/src/emitters/azure/mod.rs b/crates/alien-terraform/src/emitters/azure/mod.rs index 56d0cb0d8..ff18bd49d 100644 --- a/crates/alien-terraform/src/emitters/azure/mod.rs +++ b/crates/alien-terraform/src/emitters/azure/mod.rs @@ -11,6 +11,7 @@ //! Per-resource design notes cover storage-account naming convergence, //! cross-tenant federated-identity trust, and AKS overlay activation. +pub mod ai; pub mod artifact_registry; pub mod build; pub mod container_apps_environment; @@ -28,6 +29,7 @@ pub mod storage_account; pub mod vault; pub mod worker; +pub use ai::AzureAiEmitter; pub use artifact_registry::AzureArtifactRegistryEmitter; pub use build::AzureBuildEmitter; pub use container_apps_environment::AzureContainerAppsEnvironmentEmitter; diff --git a/crates/alien-terraform/src/emitters/gcp/ai.rs b/crates/alien-terraform/src/emitters/gcp/ai.rs new file mode 100644 index 000000000..82e7fcd09 --- /dev/null +++ b/crates/alien-terraform/src/emitters/gcp/ai.rs @@ -0,0 +1,39 @@ +//! GCP AI — Vertex AI inference gateway. +//! +//! Vertex AI is a project-level service with no per-stack resource to +//! provision. The emitter returns an empty fragment and carries the project +//! and location in the import ref so the controller can reconstruct the +//! Vertex AI endpoint without a cloud round-trip. +//! +//! The `aiplatform.googleapis.com` API enablement is handled by the +//! `GcpServiceActivationEmitter` when the preflight injects a +//! `ServiceActivation` for that API. The `ai/invoke` custom IAM role (predict +//! only) is emitted by `GcpServiceAccountEmitter` when a permission profile +//! references `ai/invoke`. + +use crate::{ + emitter::{TfEmitter, TfFragment}, + emitters::gcp::helpers::{downcast, required_label}, + expr, +}; +use alien_core::{import::EmitContext, Ai, Result}; +use hcl::expr::Expression; + +#[derive(Debug, Clone, Copy, Default)] +pub struct GcpAiEmitter; + +impl TfEmitter for GcpAiEmitter { + fn emit(&self, ctx: &EmitContext<'_>) -> Result { + let _ = downcast::(ctx, Ai::RESOURCE_TYPE)?; + Ok(TfFragment::empty()) + } + + fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { + let _ = downcast::(ctx, Ai::RESOURCE_TYPE)?; + let _ = required_label(ctx)?; + Ok(expr::object([ + ("projectId", expr::raw("var.gcp_project")), + ("location", expr::raw("var.gcp_region")), + ])) + } +} diff --git a/crates/alien-terraform/src/emitters/gcp/mod.rs b/crates/alien-terraform/src/emitters/gcp/mod.rs index e1306308c..a4c4b2146 100644 --- a/crates/alien-terraform/src/emitters/gcp/mod.rs +++ b/crates/alien-terraform/src/emitters/gcp/mod.rs @@ -5,6 +5,7 @@ //! (downcast, labels, IAM member binding, service-account email //! resolution) live in [`helpers`]. +pub mod ai; pub mod artifact_registry; pub mod build; pub mod helpers; @@ -18,6 +19,7 @@ pub mod storage; pub mod vault; pub mod worker; +pub use ai::GcpAiEmitter; pub use artifact_registry::GcpArtifactRegistryEmitter; pub use build::GcpBuildEmitter; pub use kv::GcpKvEmitter; diff --git a/crates/alien-terraform/tests/generator/aws_data_layer_tests.rs b/crates/alien-terraform/tests/generator/aws_data_layer_tests.rs index 05bdecd41..a18e20f5f 100644 --- a/crates/alien-terraform/tests/generator/aws_data_layer_tests.rs +++ b/crates/alien-terraform/tests/generator/aws_data_layer_tests.rs @@ -1,4 +1,4 @@ -//! AWS data-layer scenarios — storage / kv / queue / vault. +//! AWS data-layer scenarios — storage / kv / queue / vault / ai. //! //! Each scenario is one multi-file snapshot so reviewers see the //! complete module a developer would `terraform apply`. Every scenario @@ -7,7 +7,7 @@ use super::helpers::{assert_terraform_valid, render, snapshot_module}; use alien_core::{ - Kv, LifecycleRule, PermissionProfile, Queue, ResourceLifecycle, ServiceAccount, Stack, + Ai, Kv, LifecycleRule, PermissionProfile, Queue, ResourceLifecycle, ServiceAccount, Stack, StackSettings, Storage, Vault, }; use alien_terraform::TerraformTarget; @@ -148,3 +148,58 @@ fn aws_data_layer_renders_complete_stack() { snapshot_module("aws_data_layer_full", &module); assert_terraform_valid(&module, "aws_data_layer_full"); } + +#[test] +fn aws_ai_emits_only_import_data() { + // AWS Bedrock has no per-stack cloud resource to provision. The emitter + // returns an empty fragment so only the import metadata JSON is produced. + let stack = Stack::new("acme-ai".to_string()) + .add(Ai::new("llm".to_string()).build(), ResourceLifecycle::Frozen) + .build(); + let module = render(&stack, TerraformTarget::Aws, StackSettings::default()); + snapshot_module("aws_ai_minimal", &module); + assert_terraform_valid(&module, "aws_ai_minimal"); + + // Import metadata must carry the region so the controller can reconstruct + // the Bedrock endpoint. The import ref appears in locals.tf. + let locals = module + .get("locals.tf") + .expect("locals.tf should render"); + assert!(locals.contains("region"), "import ref must carry region"); +} + +#[test] +fn aws_ai_invoke_permissions_attach_to_service_account_role() { + // When a permission profile references ai/invoke, the AI emitter attaches the + // bedrock IAM policy to the workload (service-account) role. + let stack = Stack::new("acme-ai".to_string()) + .permission( + "execution", + PermissionProfile::new().resource("llm", ["ai/invoke"]), + ) + .add( + ServiceAccount::new("execution-sa".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add(Ai::new("llm".to_string()).build(), ResourceLifecycle::Frozen) + .build(); + let module = render(&stack, TerraformTarget::Aws, StackSettings::default()); + let rendered = module + .iter() + .map(|(_, contents)| contents) + .collect::(); + + assert!( + rendered.contains("bedrock:InvokeModel"), + "bedrock InvokeModel action must appear" + ); + assert!( + rendered.contains("bedrock:InvokeModelWithResponseStream"), + "bedrock InvokeModelWithResponseStream action must appear" + ); + assert!( + rendered.contains("arn:aws:bedrock:*::foundation-model/*"), + "bedrock foundation-model ARN must appear" + ); + assert_terraform_valid(&module, "aws_ai_invoke_permissions"); +} diff --git a/crates/alien-terraform/tests/generator/azure_data_layer_tests.rs b/crates/alien-terraform/tests/generator/azure_data_layer_tests.rs index 3741d809c..f881f04e4 100644 --- a/crates/alien-terraform/tests/generator/azure_data_layer_tests.rs +++ b/crates/alien-terraform/tests/generator/azure_data_layer_tests.rs @@ -1,4 +1,4 @@ -//! Azure data-layer scenarios — storage / kv / queue / vault. +//! Azure data-layer scenarios — storage / kv / queue / vault / ai. //! //! Mirror of `gcp_data_layer_tests.rs` for Azure. Each scenario is a //! single multi-file snapshot — the security team reads the full @@ -13,7 +13,7 @@ use super::helpers::{assert_terraform_valid, render, snapshot_module}; use alien_core::{ - AzureResourceGroup, AzureServiceBusNamespace, AzureStorageAccount, Kv, LifecycleRule, + Ai, AzureResourceGroup, AzureServiceBusNamespace, AzureStorageAccount, Kv, LifecycleRule, PermissionProfile, Queue, ResourceLifecycle, ResourceRef, ServiceAccount, Stack, StackSettings, Storage, Vault, }; @@ -274,3 +274,81 @@ fn azure_data_layer_renders_complete_stack() { snapshot_module("azure_data_layer_full", &module); assert_terraform_valid(&module, "azure_data_layer_full"); } + +#[test] +fn azure_ai_renders_cognitive_account() { + // Azure AI provisions an azurerm_cognitive_account (kind=AIServices, sku=S0). + let stack = Stack::new("acme-ai".to_string()) + .add(resource_group(), ResourceLifecycle::Frozen) + .add(Ai::new("llm".to_string()).build(), ResourceLifecycle::Frozen) + .build(); + let module = render(&stack, TerraformTarget::Azure, StackSettings::default()); + snapshot_module("azure_ai_minimal", &module); + assert_terraform_valid(&module, "azure_ai_minimal"); + + let rendered = module + .iter() + .map(|(_, contents)| contents) + .collect::(); + assert!( + rendered.contains("azurerm_cognitive_account"), + "must emit azurerm_cognitive_account" + ); + assert!(rendered.contains("AIServices"), "kind must be AIServices"); + assert!(rendered.contains("S0"), "sku_name must be S0"); + + assert!( + rendered.contains("azurerm_cognitive_deployment"), + "must emit a model deployment" + ); + assert!(rendered.contains("gpt-4.1"), "must deploy the curated gpt-4.1 model"); + assert!( + rendered.contains("cognitive_account_id"), + "deployment must reference the account via cognitive_account_id" + ); + assert!( + rendered.contains("GlobalStandard"), + "deployment sku must be GlobalStandard" + ); + + // Import metadata must carry accountName, endpoint, resourceGroup, location. + // The import ref appears in locals.tf. + let locals = module + .get("locals.tf") + .expect("locals.tf should render"); + assert!(locals.contains("accountName"), "import ref must carry accountName"); + assert!(locals.contains("endpoint"), "import ref must carry endpoint"); + assert!(locals.contains("resourceGroup"), "import ref must carry resourceGroup"); + assert!(locals.contains("location"), "import ref must carry location"); +} + +#[test] +fn azure_ai_invoke_permissions_emit_cognitive_services_openai_user_role() { + // When a permission profile references ai/invoke, the AI emitter emits a + // Cognitive Services OpenAI User role assignment scoped to the cognitive + // account, bound to the workload service account. + let stack = Stack::new("acme-ai".to_string()) + .permissions(alien_core::PermissionsConfig::new().with_profile( + "execution", + PermissionProfile::new().resource("llm", ["ai/invoke"]), + )) + .add(resource_group(), ResourceLifecycle::Frozen) + .add( + ServiceAccount::new("execution-sa".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add(Ai::new("llm".to_string()).build(), ResourceLifecycle::Frozen) + .build(); + let module = render(&stack, TerraformTarget::Azure, StackSettings::default()); + let rendered = module + .iter() + .map(|(_, contents)| contents) + .collect::(); + + // The predefined role ID for "Cognitive Services OpenAI User" must appear. + assert!( + rendered.contains("5e0bd9bd-7b93-4f28-af87-19fc36ad61bd"), + "Cognitive Services OpenAI User role ID must appear" + ); + assert_terraform_valid(&module, "azure_ai_invoke_permissions"); +} diff --git a/crates/alien-terraform/tests/generator/gcp_data_layer_tests.rs b/crates/alien-terraform/tests/generator/gcp_data_layer_tests.rs index cc737294b..ab1201aaa 100644 --- a/crates/alien-terraform/tests/generator/gcp_data_layer_tests.rs +++ b/crates/alien-terraform/tests/generator/gcp_data_layer_tests.rs @@ -1,4 +1,4 @@ -//! GCP data-layer scenarios — storage / kv / queue / vault. +//! GCP data-layer scenarios — storage / kv / queue / vault / ai. //! //! Each scenario is one multi-file snapshot so reviewers see the //! complete module a developer would `terraform apply`. Every scenario @@ -7,7 +7,7 @@ use super::helpers::{assert_terraform_valid, render, snapshot_module}; use alien_core::{ - Kv, LifecycleRule, ManagementPermissions, PermissionProfile, PermissionsConfig, Queue, + Ai, Kv, LifecycleRule, ManagementPermissions, PermissionProfile, PermissionsConfig, Queue, RemoteStackManagement, ResourceLifecycle, ServiceAccount, Stack, StackSettings, Storage, Vault, }; use alien_terraform::TerraformTarget; @@ -204,3 +204,56 @@ fn gcp_data_layer_renders_complete_stack() { snapshot_module("gcp_data_layer_full", &module); assert_terraform_valid(&module, "gcp_data_layer_full"); } + +#[test] +fn gcp_ai_emits_only_import_data() { + // GCP Vertex AI has no per-stack cloud resource to provision. The emitter + // returns an empty fragment so only the import metadata JSON is produced. + let stack = Stack::new("acme-ai".to_string()) + .add(Ai::new("llm".to_string()).build(), ResourceLifecycle::Frozen) + .build(); + let module = render(&stack, TerraformTarget::Gcp, StackSettings::default()); + snapshot_module("gcp_ai_minimal", &module); + assert_terraform_valid(&module, "gcp_ai_minimal"); + + // Import metadata must carry project and location so the controller can + // reconstruct the Vertex AI endpoint. The import ref appears in locals.tf. + let locals = module + .get("locals.tf") + .expect("locals.tf should render"); + assert!(locals.contains("projectId"), "import ref must carry projectId"); + assert!(locals.contains("location"), "import ref must carry location"); +} + +#[test] +fn gcp_ai_invoke_permissions_attach_to_service_account() { + // When a permission profile references ai/invoke, the AI emitter emits a custom + // role containing only predict permissions (not the over-broad + // roles/aiplatform.user) and binds it to the workload service account. + let stack = Stack::new("acme-ai".to_string()) + .permissions(PermissionsConfig::new().with_profile( + "execution", + PermissionProfile::new().resource("llm", ["ai/invoke"]), + )) + .add( + ServiceAccount::new("execution-sa".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add(Ai::new("llm".to_string()).build(), ResourceLifecycle::Frozen) + .build(); + let module = render(&stack, TerraformTarget::Gcp, StackSettings::default()); + let rendered = module + .iter() + .map(|(_, contents)| contents) + .collect::(); + + assert!( + !rendered.contains("roles/aiplatform.user"), + "roles/aiplatform.user must NOT appear in rendered output; ai/invoke uses a least-privilege custom role" + ); + assert!( + rendered.contains("aiplatform.endpoints.predict"), + "aiplatform.endpoints.predict must appear in the custom role in rendered output" + ); + assert_terraform_valid(&module, "gcp_ai_invoke_permissions"); +} diff --git a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_ai_minimal.snap b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_ai_minimal.snap new file mode 100644 index 000000000..11882948c --- /dev/null +++ b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_ai_minimal.snap @@ -0,0 +1,290 @@ +--- +source: crates/alien-terraform/tests/generator/helpers.rs +expression: buf +--- +=== versions.tf === +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.6" + } + } +} + +=== variables.tf === +variable "resource_prefix" { + type = string + description = "Optional stable physical resource prefix. Leave empty to generate one." + default = "" + + validation { + condition = var.resource_prefix == "" || (can(regex("^[a-z][a-z0-9-]{1,38}[a-z0-9]$", var.resource_prefix)) && length(regexall("--", var.resource_prefix)) == 0) + error_message = "resource_prefix must be 3-40 characters: lowercase letters, numbers, and hyphens; start with a letter; end with a letter or number; and not contain consecutive hyphens." + } +} + +variable "name" { + type = string + description = "Human-readable application name shown in setup and cloud IAM review metadata." +} + +variable "token" { + type = string + description = "Install token from the application setup page. This is the same token used by the deploy CLI --token flag." + sensitive = true +} + +variable "management_url" { + type = string + description = "Optional management endpoint used by pull-style runtimes." + default = "" +} + +variable "deployment_model" { + type = string + description = "How runtime updates are delivered after setup." + default = "push" + + validation { + condition = contains(["push", "pull"], var.deployment_model) + error_message = "deployment_model must be one of: push, pull." + } +} + +variable "advanced_settings_json" { + type = string + description = "Advanced JSON-encoded deployment settings. Most installations should use the typed variables in this module instead." + default = "{}" + sensitive = true +} + +variable "advanced_settings_overlay_json" { + type = string + description = "JSON-encoded deployment settings merged over the package defaults. Use this for partial advanced-setting overrides that must preserve generated defaults such as compute selections." + default = "{}" + sensitive = true +} + +variable "updates_mode" { + type = string + description = "How application updates are delivered after setup." + default = "auto" + + validation { + condition = contains(["auto", "approval-required"], var.updates_mode) + error_message = "updates_mode must be one of: auto, approval-required." + } +} + +variable "telemetry_mode" { + type = string + description = "How logs, metrics, and traces are collected." + default = "auto" + + validation { + condition = contains(["off", "auto", "approval-required"], var.telemetry_mode) + error_message = "telemetry_mode must be one of: off, auto, approval-required." + } +} + +variable "heartbeats_mode" { + type = string + description = "Whether runtime health checks are enabled." + default = "on" + + validation { + condition = contains(["off", "on"], var.heartbeats_mode) + error_message = "heartbeats_mode must be one of: off, on." + } +} + +variable "aws_region" { + type = string + description = "AWS region used by the AWS provider." + default = "us-east-1" +} + +variable "managing_role_arn" { + type = string + description = "ARN of the management identity allowed to assume setup-created roles." + default = "" +} + +variable "managing_account_id" { + type = string + description = "AWS account ID that hosts application container images. Empty disables scoped cross-account image-pull grants." + default = "" +} + +=== providers.tf === +provider "aws" { + region = var.aws_region +} + +data "aws_caller_identity" "current" {} + +data "aws_region" "current" {} + +=== resource_prefix.tf === +resource "random_id" "resource_prefix" { + byte_length = 4 +} + +=== locals.tf === +locals { + resource_prefix = var.resource_prefix == "" ? format("a%s", random_id.resource_prefix.hex) : var.resource_prefix + deployment_name = var.name + deployment_platform = "aws" + deployment_target = "aws" + deployment_region = data.aws_region.current.region + deployment_management_config = null + advanced_settings = merge(jsondecode(var.advanced_settings_json), jsondecode(var.advanced_settings_overlay_json)) + deployment_settings = merge(local.advanced_settings, { deploymentModel = var.deployment_model, updates = var.updates_mode, telemetry = var.telemetry_mode, heartbeats = var.heartbeats_mode }) + deployment_resources = [ + { + id = "llm" + type = "ai" + importData = { + region = data.aws_region.current.region + } + } + ] +} + +=== registration.tf === +resource "terraform_data" "deployment_registration" { + input = { + platform = local.deployment_platform + token = var.token + name = var.name + resource_prefix = local.resource_prefix + setup_target = "" + setup_import_format_version = 1 + setup_fingerprint = "" + setup_fingerprint_version = 0 + management_url = var.management_url + management_config = local.deployment_management_config + stack_settings = local.deployment_settings + resources = local.deployment_resources + inputValues = {} + } +} + +=== outputs.tf === +output "deployment_target" { + value = "aws" + description = "Terraform module target." +} + +output "deployment_resource_prefix" { + value = local.resource_prefix + description = "Physical resource prefix." +} + +output "deployment_platform" { + value = local.deployment_platform + description = "Target platform." +} + +output "deployment_region" { + value = local.deployment_region + description = "Target cloud region or location." +} + +output "deployment_setup_target" { + value = "" + description = "Setup target." +} + +output "deployment_setup_import_format_version" { + value = 1 + description = "Setup registration payload format version." +} + +output "deployment_setup_fingerprint" { + value = "" + description = "Setup compatibility fingerprint." +} + +output "deployment_setup_fingerprint_version" { + value = 0 + description = "Setup fingerprint algorithm version." +} + +output "deployment_management_config" { + value = jsonencode(local.deployment_management_config) + description = "Deployment registration management configuration JSON." +} + +output "deployment_stack_settings" { + value = jsonencode(local.deployment_settings) + description = "Deployment registration settings JSON." + sensitive = true +} + +output "deployment_resources" { + value = jsonencode(local.deployment_resources) + description = "Deployment registration resource metadata JSON." +} + +=== README.md === +# Deployment setup - acme-ai + +Target: `aws`. + +This module creates setup-owned infrastructure, grants the management access needed after setup, and prepares deployment registration metadata. Review the generated `.tf` files before applying; each resource file maps to one setup resource. + +## Inputs + +Required: + +- `token`: install token from the setup page. +- `name`: deployment name to include in the registration metadata. + +Common optional settings: + +- `resource_prefix`: stable physical-name prefix. Leave empty to generate one. +- `management_url`: optional management endpoint used by pull-style runtimes. +- `deployment_model`: `push` or `pull`. +- `updates_mode`: `auto` or `approval-required`. +- `telemetry_mode`: `off`, `auto`, or `approval-required`. +- `heartbeats_mode`: `off` or `on`. +- `advanced_settings_json`: complete advanced deployment settings JSON. Most installs should keep the generated default. +- `advanced_settings_overlay_json`: partial advanced settings merged over package defaults, preserving generated values such as compute selections. + +AWS settings: + +- `aws_region`: AWS region used by the provider. +- `managing_role_arn`: management identity allowed to assume setup-created roles. +- `managing_account_id`: account that hosts application container images. Empty disables scoped cross-account image-pull grants. + +## Run + +Use your organization's normal backend and approval workflow. A typical local review looks like: + +```bash +export TF_VAR_name="acme-ai" +export TF_VAR_token="..." +terraform init +terraform validate +terraform plan -out=tfplan +terraform apply tfplan +``` + +## Registration + +This module exposes `deployment_management_config`, `deployment_stack_settings`, and `deployment_resources` outputs for registration flows managed outside Terraform. + +## Outputs + +- `deployment_management_config`: management endpoint and credential-boundary metadata. +- `deployment_stack_settings`: deployment settings JSON assembled from typed variables, package defaults, and advanced-setting overlays. +- `deployment_resources`: setup-owned resource metadata handed to the deployment runtime. +- `deployment_id` and `deployment_token`: emitted only when Terraform performs registration. diff --git a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__azure_ai_minimal.snap b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__azure_ai_minimal.snap new file mode 100644 index 000000000..c4bdef90b --- /dev/null +++ b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__azure_ai_minimal.snap @@ -0,0 +1,398 @@ +--- +source: crates/alien-terraform/tests/generator/helpers.rs +expression: buf +--- +=== versions.tf === +terraform { + required_version = ">= 1.5.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.100" + } + random = { + source = "hashicorp/random" + version = ">= 3.6" + } + } +} + +=== variables.tf === +variable "resource_prefix" { + type = string + description = "Optional stable physical resource prefix. Leave empty to generate one." + default = "" + + validation { + condition = var.resource_prefix == "" || (can(regex("^[a-z][a-z0-9-]{1,38}[a-z0-9]$", var.resource_prefix)) && length(regexall("--", var.resource_prefix)) == 0) + error_message = "resource_prefix must be 3-40 characters: lowercase letters, numbers, and hyphens; start with a letter; end with a letter or number; and not contain consecutive hyphens." + } +} + +variable "name" { + type = string + description = "Human-readable application name shown in setup and cloud IAM review metadata." +} + +variable "token" { + type = string + description = "Install token from the application setup page. This is the same token used by the deploy CLI --token flag." + sensitive = true +} + +variable "management_url" { + type = string + description = "Optional management endpoint used by pull-style runtimes." + default = "" +} + +variable "deployment_model" { + type = string + description = "How runtime updates are delivered after setup." + default = "push" + + validation { + condition = contains(["push", "pull"], var.deployment_model) + error_message = "deployment_model must be one of: push, pull." + } +} + +variable "advanced_settings_json" { + type = string + description = "Advanced JSON-encoded deployment settings. Most installations should use the typed variables in this module instead." + default = "{}" + sensitive = true +} + +variable "advanced_settings_overlay_json" { + type = string + description = "JSON-encoded deployment settings merged over the package defaults. Use this for partial advanced-setting overrides that must preserve generated defaults such as compute selections." + default = "{}" + sensitive = true +} + +variable "updates_mode" { + type = string + description = "How application updates are delivered after setup." + default = "auto" + + validation { + condition = contains(["auto", "approval-required"], var.updates_mode) + error_message = "updates_mode must be one of: auto, approval-required." + } +} + +variable "telemetry_mode" { + type = string + description = "How logs, metrics, and traces are collected." + default = "auto" + + validation { + condition = contains(["off", "auto", "approval-required"], var.telemetry_mode) + error_message = "telemetry_mode must be one of: off, auto, approval-required." + } +} + +variable "heartbeats_mode" { + type = string + description = "Whether runtime health checks are enabled." + default = "on" + + validation { + condition = contains(["off", "on"], var.heartbeats_mode) + error_message = "heartbeats_mode must be one of: off, on." + } +} + +variable "azure_location" { + type = string + description = "Azure location." + default = "eastus" +} + +variable "azure_subscription_id" { + type = string + description = "Azure subscription ID hosting the stack." +} + +variable "azure_resource_group_name" { + type = string + description = "Azure resource group name." +} + +=== providers.tf === +provider "azurerm" { + resource_provider_registrations = "none" + + features {} +} + +=== resource_prefix.tf === +resource "random_id" "resource_prefix" { + byte_length = 4 +} + +=== locals.tf === +locals { + resource_prefix = var.resource_prefix == "" ? format("a%s", random_id.resource_prefix.hex) : var.resource_prefix + deployment_name = var.name + deployment_platform = "azure" + deployment_target = "azure" + deployment_region = var.azure_location + deployment_management_config = null + advanced_settings = merge(jsondecode(var.advanced_settings_json), jsondecode(var.advanced_settings_overlay_json)) + deployment_settings = merge(local.advanced_settings, { deploymentModel = var.deployment_model, updates = var.updates_mode, telemetry = var.telemetry_mode, heartbeats = var.heartbeats_mode }) + deployment_resources = [ + { + id = "default-resource-group" + type = "azure_resource_group" + importData = { + subscriptionId = var.azure_subscription_id + resourceGroup = azurerm_resource_group.default_resource_group.name + location = azurerm_resource_group.default_resource_group.location + } + }, + { + id = "llm" + type = "ai" + importData = { + subscriptionId = var.azure_subscription_id + resourceGroup = var.azure_resource_group_name + accountName = azurerm_cognitive_account.llm.name + endpoint = azurerm_cognitive_account.llm.endpoint + location = var.azure_location + } + } + ] +} + +=== default_resource_group.tf === +resource "azurerm_resource_group" "default_resource_group" { + name = var.azure_resource_group_name + location = var.azure_location + tags = { + "managed-by" = "setup" + deployment = local.resource_prefix + resource = "default-resource-group" + "resource-type" = "resource-group" + } +} + +=== llm.tf === +resource "azurerm_cognitive_account" "llm" { + name = "${local.resource_prefix}-llm" + resource_group_name = var.azure_resource_group_name + location = var.azure_location + kind = "AIServices" + sku_name = "S0" + custom_subdomain_name = "${local.resource_prefix}-llm" + tags = { + "managed-by" = "setup" + deployment = local.resource_prefix + resource = "llm" + "resource-type" = "ai" + } + depends_on = [ + azurerm_resource_group.default_resource_group + ] +} + +resource "azurerm_cognitive_deployment" "llm_gpt-4_1" { + name = "gpt-4.1" + cognitive_account_id = azurerm_cognitive_account.llm.id + + model { + format = "OpenAI" + name = "gpt-4.1" + version = "2025-04-14" + } + + sku { + name = "GlobalStandard" + capacity = 1 + } + + depends_on = [ + azurerm_resource_group.default_resource_group + ] +} + +resource "azurerm_cognitive_deployment" "llm_gpt-4o-mini" { + name = "gpt-4o-mini" + cognitive_account_id = azurerm_cognitive_account.llm.id + + model { + format = "OpenAI" + name = "gpt-4o-mini" + version = "2024-07-18" + } + + sku { + name = "GlobalStandard" + capacity = 1 + } + + depends_on = [ + azurerm_resource_group.default_resource_group + ] +} + +resource "azurerm_cognitive_deployment" "llm_model-router" { + name = "model-router" + cognitive_account_id = azurerm_cognitive_account.llm.id + + model { + format = "OpenAI" + name = "model-router" + version = "2025-11-18" + } + + sku { + name = "GlobalStandard" + capacity = 1 + } + + depends_on = [ + azurerm_resource_group.default_resource_group + ] +} + +=== registration.tf === +resource "terraform_data" "deployment_registration" { + input = { + platform = local.deployment_platform + token = var.token + name = var.name + resource_prefix = local.resource_prefix + setup_target = "" + setup_import_format_version = 1 + setup_fingerprint = "" + setup_fingerprint_version = 0 + management_url = var.management_url + management_config = local.deployment_management_config + stack_settings = local.deployment_settings + resources = local.deployment_resources + inputValues = {} + } + depends_on = [ + azurerm_resource_group.default_resource_group, + azurerm_cognitive_account.llm, + azurerm_cognitive_deployment.llm_gpt-4_1, + azurerm_cognitive_deployment.llm_gpt-4o-mini, + azurerm_cognitive_deployment.llm_model-router + ] +} + +=== outputs.tf === +output "deployment_target" { + value = "azure" + description = "Terraform module target." +} + +output "deployment_resource_prefix" { + value = local.resource_prefix + description = "Physical resource prefix." +} + +output "deployment_platform" { + value = local.deployment_platform + description = "Target platform." +} + +output "deployment_region" { + value = local.deployment_region + description = "Target cloud region or location." +} + +output "deployment_setup_target" { + value = "" + description = "Setup target." +} + +output "deployment_setup_import_format_version" { + value = 1 + description = "Setup registration payload format version." +} + +output "deployment_setup_fingerprint" { + value = "" + description = "Setup compatibility fingerprint." +} + +output "deployment_setup_fingerprint_version" { + value = 0 + description = "Setup fingerprint algorithm version." +} + +output "deployment_management_config" { + value = jsonencode(local.deployment_management_config) + description = "Deployment registration management configuration JSON." +} + +output "deployment_stack_settings" { + value = jsonencode(local.deployment_settings) + description = "Deployment registration settings JSON." + sensitive = true +} + +output "deployment_resources" { + value = jsonencode(local.deployment_resources) + description = "Deployment registration resource metadata JSON." +} + +=== README.md === +# Deployment setup - acme-ai + +Target: `azure`. + +This module creates setup-owned infrastructure, grants the management access needed after setup, and prepares deployment registration metadata. Review the generated `.tf` files before applying; each resource file maps to one setup resource. + +## Inputs + +Required: + +- `token`: install token from the setup page. +- `name`: deployment name to include in the registration metadata. + +Common optional settings: + +- `resource_prefix`: stable physical-name prefix. Leave empty to generate one. +- `management_url`: optional management endpoint used by pull-style runtimes. +- `deployment_model`: `push` or `pull`. +- `updates_mode`: `auto` or `approval-required`. +- `telemetry_mode`: `off`, `auto`, or `approval-required`. +- `heartbeats_mode`: `off` or `on`. +- `advanced_settings_json`: complete advanced deployment settings JSON. Most installs should keep the generated default. +- `advanced_settings_overlay_json`: partial advanced settings merged over package defaults, preserving generated values such as compute selections. + +Azure settings: + +- `azure_subscription_id`: target subscription ID. +- `azure_location`: Azure location. +- `azure_resource_group_name`: target resource group name. +- `azure_managing_tenant_id`, `azure_oidc_issuer`, `azure_oidc_subject`: management identity trust settings when this setup grants Azure management access. + +## Run + +Use your organization's normal backend and approval workflow. A typical local review looks like: + +```bash +export TF_VAR_name="acme-ai" +export TF_VAR_token="..." +terraform init +terraform validate +terraform plan -out=tfplan +terraform apply tfplan +``` + +## Registration + +This module exposes `deployment_management_config`, `deployment_stack_settings`, and `deployment_resources` outputs for registration flows managed outside Terraform. + +## Outputs + +- `deployment_management_config`: management endpoint and credential-boundary metadata. +- `deployment_stack_settings`: deployment settings JSON assembled from typed variables, package defaults, and advanced-setting overlays. +- `deployment_resources`: setup-owned resource metadata handed to the deployment runtime. +- `deployment_id` and `deployment_token`: emitted only when Terraform performs registration. diff --git a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__gcp_ai_minimal.snap b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__gcp_ai_minimal.snap new file mode 100644 index 000000000..4caf7cf11 --- /dev/null +++ b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__gcp_ai_minimal.snap @@ -0,0 +1,302 @@ +--- +source: crates/alien-terraform/tests/generator/helpers.rs +expression: buf +--- +=== versions.tf === +terraform { + required_version = ">= 1.5.0" + + required_providers { + google = { + source = "hashicorp/google" + version = ">= 5.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.6" + } + } +} + +=== variables.tf === +variable "resource_prefix" { + type = string + description = "Optional stable physical resource prefix. Leave empty to generate one." + default = "" + + validation { + condition = var.resource_prefix == "" || (can(regex("^[a-z][a-z0-9-]{1,38}[a-z0-9]$", var.resource_prefix)) && length(regexall("--", var.resource_prefix)) == 0) + error_message = "resource_prefix must be 3-40 characters: lowercase letters, numbers, and hyphens; start with a letter; end with a letter or number; and not contain consecutive hyphens." + } +} + +variable "name" { + type = string + description = "Human-readable application name shown in setup and cloud IAM review metadata." +} + +variable "token" { + type = string + description = "Install token from the application setup page. This is the same token used by the deploy CLI --token flag." + sensitive = true +} + +variable "management_url" { + type = string + description = "Optional management endpoint used by pull-style runtimes." + default = "" +} + +variable "deployment_model" { + type = string + description = "How runtime updates are delivered after setup." + default = "push" + + validation { + condition = contains(["push", "pull"], var.deployment_model) + error_message = "deployment_model must be one of: push, pull." + } +} + +variable "advanced_settings_json" { + type = string + description = "Advanced JSON-encoded deployment settings. Most installations should use the typed variables in this module instead." + default = "{}" + sensitive = true +} + +variable "advanced_settings_overlay_json" { + type = string + description = "JSON-encoded deployment settings merged over the package defaults. Use this for partial advanced-setting overrides that must preserve generated defaults such as compute selections." + default = "{}" + sensitive = true +} + +variable "updates_mode" { + type = string + description = "How application updates are delivered after setup." + default = "auto" + + validation { + condition = contains(["auto", "approval-required"], var.updates_mode) + error_message = "updates_mode must be one of: auto, approval-required." + } +} + +variable "telemetry_mode" { + type = string + description = "How logs, metrics, and traces are collected." + default = "auto" + + validation { + condition = contains(["off", "auto", "approval-required"], var.telemetry_mode) + error_message = "telemetry_mode must be one of: off, auto, approval-required." + } +} + +variable "heartbeats_mode" { + type = string + description = "Whether runtime health checks are enabled." + default = "on" + + validation { + condition = contains(["off", "on"], var.heartbeats_mode) + error_message = "heartbeats_mode must be one of: off, on." + } +} + +variable "gcp_project" { + type = string + description = "GCP project ID." +} + +variable "gcp_region" { + type = string + description = "GCP region." + default = "us-central1" +} + +variable "managing_service_account_email" { + type = string + description = "Email of the management service account allowed to impersonate setup-created identities. Empty disables the binding." + default = "" +} + +variable "gcp_manage_custom_roles" { + type = bool + description = "Whether this module creates the GCP project custom roles it binds. Set to false when those roles are managed outside this stack." + default = true +} + +variable "gcp_custom_role_prefix" { + type = string + description = "Prefix used for GCP project custom role IDs when gcp_manage_custom_roles is false. Empty uses resource_prefix." + default = "" +} + +=== providers.tf === +provider "google" { + project = var.gcp_project + region = var.gcp_region +} + +=== resource_prefix.tf === +resource "random_id" "resource_prefix" { + byte_length = 4 +} + +=== locals.tf === +locals { + resource_prefix = var.resource_prefix == "" ? format("a%s", random_id.resource_prefix.hex) : var.resource_prefix + deployment_name = var.name + gcp_custom_role_prefix = substr(replace(lower(var.gcp_custom_role_prefix == "" ? local.resource_prefix : var.gcp_custom_role_prefix), "-", "_"), 0, 18) + deployment_platform = "gcp" + deployment_target = "gcp" + deployment_region = var.gcp_region + deployment_management_config = null + advanced_settings = merge(jsondecode(var.advanced_settings_json), jsondecode(var.advanced_settings_overlay_json)) + deployment_settings = merge(local.advanced_settings, { deploymentModel = var.deployment_model, updates = var.updates_mode, telemetry = var.telemetry_mode, heartbeats = var.heartbeats_mode }) + deployment_resources = [ + { + id = "llm" + type = "ai" + importData = { + projectId = var.gcp_project + location = var.gcp_region + } + } + ] +} + +=== registration.tf === +resource "terraform_data" "deployment_registration" { + input = { + platform = local.deployment_platform + token = var.token + name = var.name + resource_prefix = local.resource_prefix + setup_target = "" + setup_import_format_version = 1 + setup_fingerprint = "" + setup_fingerprint_version = 0 + management_url = var.management_url + management_config = local.deployment_management_config + stack_settings = local.deployment_settings + resources = local.deployment_resources + inputValues = {} + } +} + +=== outputs.tf === +output "deployment_target" { + value = "gcp" + description = "Terraform module target." +} + +output "deployment_resource_prefix" { + value = local.resource_prefix + description = "Physical resource prefix." +} + +output "deployment_platform" { + value = local.deployment_platform + description = "Target platform." +} + +output "deployment_region" { + value = local.deployment_region + description = "Target cloud region or location." +} + +output "deployment_setup_target" { + value = "" + description = "Setup target." +} + +output "deployment_setup_import_format_version" { + value = 1 + description = "Setup registration payload format version." +} + +output "deployment_setup_fingerprint" { + value = "" + description = "Setup compatibility fingerprint." +} + +output "deployment_setup_fingerprint_version" { + value = 0 + description = "Setup fingerprint algorithm version." +} + +output "deployment_management_config" { + value = jsonencode(local.deployment_management_config) + description = "Deployment registration management configuration JSON." +} + +output "deployment_stack_settings" { + value = jsonencode(local.deployment_settings) + description = "Deployment registration settings JSON." + sensitive = true +} + +output "deployment_resources" { + value = jsonencode(local.deployment_resources) + description = "Deployment registration resource metadata JSON." +} + +=== README.md === +# Deployment setup - acme-ai + +Target: `gcp`. + +This module creates setup-owned infrastructure, grants the management access needed after setup, and prepares deployment registration metadata. Review the generated `.tf` files before applying; each resource file maps to one setup resource. + +## Inputs + +Required: + +- `token`: install token from the setup page. +- `name`: deployment name to include in the registration metadata. + +Common optional settings: + +- `resource_prefix`: stable physical-name prefix. Leave empty to generate one. +- `management_url`: optional management endpoint used by pull-style runtimes. +- `deployment_model`: `push` or `pull`. +- `updates_mode`: `auto` or `approval-required`. +- `telemetry_mode`: `off`, `auto`, or `approval-required`. +- `heartbeats_mode`: `off` or `on`. +- `advanced_settings_json`: complete advanced deployment settings JSON. Most installs should keep the generated default. +- `advanced_settings_overlay_json`: partial advanced settings merged over package defaults, preserving generated values such as compute selections. + +GCP settings: + +- `gcp_project`: target GCP project ID. +- `gcp_region`: target GCP region. +- `managing_service_account_email`: management service account allowed to impersonate setup-created identities. +- `gcp_manage_custom_roles`: whether this module creates project custom roles. +- `gcp_custom_role_prefix`: custom role ID prefix when roles are managed outside this module. + +## Run + +Use your organization's normal backend and approval workflow. A typical local review looks like: + +```bash +export TF_VAR_name="acme-ai" +export TF_VAR_token="..." +terraform init +terraform validate +terraform plan -out=tfplan +terraform apply tfplan +``` + +## Registration + +This module exposes `deployment_management_config`, `deployment_stack_settings`, and `deployment_resources` outputs for registration flows managed outside Terraform. + +## Outputs + +- `deployment_management_config`: management endpoint and credential-boundary metadata. +- `deployment_stack_settings`: deployment settings JSON assembled from typed variables, package defaults, and advanced-setting overlays. +- `deployment_resources`: setup-owned resource metadata handed to the deployment runtime. +- `deployment_id` and `deployment_token`: emitted only when Terraform performs registration. diff --git a/crates/alien-test/src/e2e.rs b/crates/alien-test/src/e2e.rs index af7ae7de5..64a578d2e 100644 --- a/crates/alien-test/src/e2e.rs +++ b/crates/alien-test/src/e2e.rs @@ -206,6 +206,8 @@ pub enum Binding { ArtifactRegistry, /// Service account identity and impersonation ServiceAccount, + /// AI Gateway binding injection + a real model invoke (skippable via ALIEN_E2E_AI_SKIP_INVOKE=1) + Ai, } impl std::fmt::Display for Binding { @@ -231,6 +233,7 @@ impl std::fmt::Display for Binding { Binding::Build => write!(f, "build"), Binding::ArtifactRegistry => write!(f, "artifact-registry"), Binding::ServiceAccount => write!(f, "service-account"), + Binding::Ai => write!(f, "ai"), } } } @@ -272,6 +275,11 @@ pub fn supported_bindings(platform: Platform, model: DeploymentModel) -> Vec { bindings.push(Binding::Kv); @@ -346,6 +354,9 @@ pub fn exclusion_reason( { Some("Bun-on-Windows runtime issue: detached async tasks in waitUntil don't execute") } + Binding::Ai if app == TestApp::ComprehensiveRust => { + Some("AI binding check not implemented in the Rust test app") + } _ => None, } } diff --git a/crates/alien-test/tests/common/bindings.rs b/crates/alien-test/tests/common/bindings.rs index 3d22132d4..906eb70d6 100644 --- a/crates/alien-test/tests/common/bindings.rs +++ b/crates/alien-test/tests/common/bindings.rs @@ -15,6 +15,7 @@ const KV_BINDING: &str = "alien-kv"; const VAULT_BINDING: &str = "alien-vault"; const POSTGRES_BINDING: &str = "alien-postgres"; const QUEUE_BINDING: &str = "alien-queue"; +const AI_BINDING: &str = "test-ai"; /// Standard response shape returned by binding test endpoints. #[derive(Debug, Deserialize)] @@ -928,3 +929,114 @@ pub async fn check_service_account(deployment: &TestDeployment) -> anyhow::Resul info!("Service account binding check passed"); Ok(()) } + +// --------------------------------------------------------------------------- +// AI Gateway +// --------------------------------------------------------------------------- + +/// Response from the /ai-test endpoint. `model_count`, `model`, and `reply` are +/// present only on `?invoke=1` responses. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AiTestResponse { + injected: bool, + service: String, + locator: Option, + model_count: Option, + model: Option, + reply: Option, +} + +/// Check the AI binding end to end: GET /ai-test, then GET /ai-test?invoke=1. +/// +/// The first call proves the runtime injected ALIEN_TEST_AI_BINDING and that it +/// parsed to a well-formed bedrock config. The second lists the model catalog and +/// makes a real one-line chat call through the embedded gateway under the +/// workload's ambient credentials — proving the full app -> gateway -> cloud LLM +/// path, not just provisioning. The invoke half needs nonzero Bedrock on-demand +/// quota in the deployment's account; set ALIEN_E2E_AI_SKIP_INVOKE=1 to run +/// provisioning-only in a quota-zeroed account. +pub async fn check_ai(deployment: &TestDeployment) -> anyhow::Result<()> { + let url = deployment_url(deployment)?; + info!(binding = AI_BINDING, "Checking AI binding injection"); + + let resp = reqwest::Client::new() + .get(format!("{}/ai-test", url)) + .send() + .await + .context("AI test request failed")?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + bail!("AI test returned {}: {}", status, body); + } + + let data: AiTestResponse = resp + .json() + .await + .context("Failed to parse AI test response")?; + + if !data.injected { + bail!( + "AI binding not injected: ALIEN_TEST_AI_BINDING was missing or unparseable. \ + Response: {:?}", + data + ); + } + let expected_service = match deployment.platform.as_str() { + "aws" => "bedrock", + "gcp" => "vertex", + "azure" => "foundry", + other => bail!("AI binding check has no expected service for platform '{other}'"), + }; + if data.service != expected_service { + bail!( + "AI binding service mismatch: expected '{}', got '{}'.", + expected_service, + data.service + ); + } + let locator = data.locator.as_deref().unwrap_or(""); + if locator.is_empty() { + bail!("AI binding locator is empty; the controller must fill the service scope (region / project / endpoint)"); + } + + info!(service = %data.service, %locator, "AI binding injection check passed"); + + if std::env::var("ALIEN_E2E_AI_SKIP_INVOKE").as_deref() == Ok("1") { + info!("Skipping the real model invoke (ALIEN_E2E_AI_SKIP_INVOKE=1)"); + return Ok(()); + } + + let resp = reqwest::Client::new() + .get(format!("{}/ai-test?invoke=1", url)) + .timeout(std::time::Duration::from_secs(120)) + .send() + .await + .context("AI invoke request failed")?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + bail!("AI invoke returned {}: {}", status, body); + } + let data: AiTestResponse = resp + .json() + .await + .context("Failed to parse AI invoke response")?; + let model_count = data.model_count.unwrap_or(0); + if model_count == 0 { + bail!("AI model catalog is empty; expected at least one curated model"); + } + let reply = data.reply.as_deref().unwrap_or(""); + if !reply.to_ascii_lowercase().contains("pong") { + bail!( + "AI chat reply did not contain 'pong': model={:?} reply={:?}", + data.model, + reply + ); + } + + info!(model = %data.model.as_deref().unwrap_or(""), %reply, "AI model invoke check passed"); + Ok(()) +} diff --git a/crates/alien-test/tests/common/runner.rs b/crates/alien-test/tests/common/runner.rs index d0282f6b1..816c5e040 100644 --- a/crates/alien-test/tests/common/runner.rs +++ b/crates/alien-test/tests/common/runner.rs @@ -61,6 +61,7 @@ pub async fn check_all_bindings( Binding::Build => bindings::check_build(deployment).await?, Binding::ArtifactRegistry => bindings::check_artifact_registry(deployment).await?, Binding::ServiceAccount => bindings::check_service_account(deployment).await?, + Binding::Ai => bindings::check_ai(deployment).await?, } info!(binding = %binding, "Binding check passed"); diff --git a/examples/ai-chatbot-ts/.dockerignore b/examples/ai-chatbot-ts/.dockerignore new file mode 100644 index 000000000..a9ba4ba8b --- /dev/null +++ b/examples/ai-chatbot-ts/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.next +.git +.env*.local diff --git a/examples/ai-chatbot-ts/.gitignore b/examples/ai-chatbot-ts/.gitignore new file mode 100644 index 000000000..2c06eeb1c --- /dev/null +++ b/examples/ai-chatbot-ts/.gitignore @@ -0,0 +1,6 @@ +node_modules +.next +out +next-env.d.ts +*.tsbuildinfo +.env*.local diff --git a/examples/ai-chatbot-ts/Dockerfile b/examples/ai-chatbot-ts/Dockerfile new file mode 100644 index 000000000..d60131824 --- /dev/null +++ b/examples/ai-chatbot-ts/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +# .next/static and public are not part of the standalone output and must be +# copied alongside it for server.js to serve them. +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/examples/ai-chatbot-ts/README.md b/examples/ai-chatbot-ts/README.md new file mode 100644 index 000000000..1b926b2f9 --- /dev/null +++ b/examples/ai-chatbot-ts/README.md @@ -0,0 +1,46 @@ +# AI chatbot + +A streaming chatbot that runs as a single container in the customer's cloud and +answers questions about a private **Postgres** it queries with a tool. No API +keys and no database credentials in the app. + +## How it works + +- `alien.ts` declares a model-less `alien.AI("llm")` and a private + `alien.Postgres("db")`, and links both to the container. At deploy time Alien + grants the workload `ai/invoke` + `postgres/data-access` and injects + `ALIEN_LLM_BINDING` and `ALIEN_DB_BINDING`. +- `app/api/chat/route.ts` resolves the model endpoint with + `getAiConnection("llm")` and streams with the Vercel AI SDK's `streamText`. + On a cloud, the binding routes through Alien's embedded OpenAI-compatible + gateway, which injects the workload's ambient cloud credential. On `alien dev` + the binding carries the developer's own provider key, and the app calls the + provider directly. +- The chat route gives the model a `queryDatabase` tool (a single read-only + SELECT against Postgres). It reads the connection with + `getPostgresConnection("db")`, which resolves the password at runtime using + the workload's own identity, so the password never sits in checked-in config. +- `app/api/models/route.ts` calls `ai("llm").getAvailableModels()` so the UI's + model picker reflects the binding's model set. +- The UI (`app/page.tsx`) is a full chat surface built on `useChat`: streamed + markdown answers, a card for every `queryDatabase` call showing the SQL and + the rows it returned, suggested questions, a model picker, and stop/retry. + +## Run it + +In the customer's cloud: + +```bash +alien deploy +``` + +Locally, bring your own provider key: + +```bash +OPENAI_API_KEY=sk-... alien dev +``` + +Open the app URL, click **Seed demo data** (or `curl -X POST /api/seed`), +and ask a data question, e.g. *"How many enterprise customers do we have and +what's the total MRR?"* The model writes the SQL, calls `queryDatabase`, and +summarizes the result. diff --git a/examples/ai-chatbot-ts/alien.ts b/examples/ai-chatbot-ts/alien.ts new file mode 100644 index 000000000..41ee67348 --- /dev/null +++ b/examples/ai-chatbot-ts/alien.ts @@ -0,0 +1,40 @@ +import * as alien from "@alienplatform/core" + +// A model-less AI resource. The customer's cloud serves the inference; the +// embedded gateway injects the workload's ambient identity, so no API keys. +const llm = new alien.AI("llm").build() + +// A private Postgres the chatbot queries through a tool. Reachable only from +// same-stack workloads; the app resolves its connection at runtime from the +// binding, never from a checked-in secret. +const db = new alien.Postgres("db").build() + +const app = new alien.Container("app") + .code({ type: "source", src: ".", toolchain: { type: "docker", dockerfile: "Dockerfile" } }) + .cpu(0.5) + .memory("512Mi") + .port(3000) + .publicEndpoint("web", 3000, "http") + // Next's standalone server reads these; HOSTNAME=0.0.0.0 binds all interfaces. + .environment({ PORT: "3000", HOSTNAME: "0.0.0.0" }) + // Linking injects ALIEN_LLM_BINDING (and starts the gateway, exposed as + // ALIEN_AI_GATEWAY_URL) and ALIEN_DB_BINDING (the Postgres connection). + .link(llm) + .link(db) + .permissions("app") + .build() + +export default new alien.Stack("ai-chatbot") + .platforms(["aws", "gcp", "azure"]) + .add(llm, "live") + .add(db, "live") + .add(app, "live") + .permissions({ + profiles: { + app: { + // invoke the model + read the DB connection-password secret and connect. + "*": ["ai/invoke", "postgres/data-access"], + }, + }, + }) + .build() diff --git a/examples/ai-chatbot-ts/app/api/chat/route.ts b/examples/ai-chatbot-ts/app/api/chat/route.ts new file mode 100644 index 000000000..0ee486683 --- /dev/null +++ b/examples/ai-chatbot-ts/app/api/chat/route.ts @@ -0,0 +1,86 @@ +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { getAiConnection, getPostgresConnection } from "@alienplatform/sdk" +import { type UIMessage, convertToModelMessages, stepCountIs, streamText, tool } from "ai" +import { Client } from "pg" +import { z } from "zod" + +// Streaming completions can outrun the default serverless request cap. +export const maxDuration = 30 + +// One shared connection per container, cached lazily, resolved lazily from the linked Postgres +// binding (ALIEN_DB_BINDING). getPostgresConnection reads the connection password +// from the cloud secret store at runtime using the workload's own identity, so no +// password is ever in the environment. +let dbConnection: Promise | undefined +function db(): Promise { + if (!dbConnection) { + dbConnection = (async () => { + const conn = await getPostgresConnection("db") + // Field style + conn.ssl, NOT conn.connectionString: node-postgres parses the + // URL's sslmode and overrides ssl, which breaks the managed-cloud cert path. + const client = new Client({ + host: conn.host, + port: conn.port, + database: conn.database, + user: conn.username, + password: conn.password, + ssl: conn.ssl, + }) + await client.connect() + return client + })().catch(err => { + // Don't cache a failed connection; let the next request retry. + dbConnection = undefined + throw err + }) + } + return dbConnection +} + +const queryDatabase = tool({ + description: + "Run a read-only SQL query against the company's private Postgres database. " + + "Tables: customers(id, name, plan, country, mrr_usd), orders(id, customer_id, amount_usd, status, created).", + inputSchema: z.object({ + sql: z.string().describe("a single read-only SELECT statement for Postgres"), + }), + execute: async ({ sql }) => { + // This tool exposes the database to a language model. node-postgres runs + // semicolon-separated statements, so a bare "starts with SELECT" check is + // bypassable (e.g. "select 1; delete ..."). Require a single statement that + // starts with SELECT: strip a trailing ";", then reject any remaining ";". + const statement = sql.trim().replace(/;\s*$/, "") + if (!/^select\b/i.test(statement) || statement.includes(";")) { + return { error: "only a single read-only SELECT statement is allowed" } + } + const client = await db() + const result = await client.query(statement) + return { rowCount: result.rowCount, rows: result.rows.slice(0, 50) } + }, +}) + +export async function POST(req: Request) { + const { messages, model }: { messages: UIMessage[]; model?: string } = await req.json() + + // Resolve the AI binding at request time (the env is only populated in the running + // workload, not at build). getAiConnection returns { baseURL, apiKey? }: a BYO-key + // provider is called directly with the key; an ambient-cloud model routes through the + // in-process gateway (started here on first use), which injects the credential. Same + // code either way. + const provider = createOpenAICompatible({ name: "alien", ...(await getAiConnection("llm")) }) + + const result = streamText({ + model: provider(model || "gpt-4o-mini"), + system: + "You answer questions about the company's data. When a question needs data, write a " + + "single read-only Postgres SELECT and call the queryDatabase tool, then summarize the " + + "result for the user in plain English.", + messages: await convertToModelMessages(messages), + // Without a stop condition the model emits the tool call and never streams the + // final answer after the tool result; this lets it loop tool-call -> result -> text. + stopWhen: stepCountIs(6), + tools: { queryDatabase }, + }) + + return result.toUIMessageStreamResponse() +} diff --git a/examples/ai-chatbot-ts/app/api/models/route.ts b/examples/ai-chatbot-ts/app/api/models/route.ts new file mode 100644 index 000000000..64e8b67be --- /dev/null +++ b/examples/ai-chatbot-ts/app/api/models/route.ts @@ -0,0 +1,8 @@ +import { ai } from "@alienplatform/sdk" + +// The gateway exposes the cloud's curated model set; getAvailableModels reads it +// so the UI can offer a picker without hardcoding model ids. +export async function GET() { + const models = await ai("llm").getAvailableModels() + return Response.json({ models: models.map(m => m.id) }) +} diff --git a/examples/ai-chatbot-ts/app/api/seed/route.ts b/examples/ai-chatbot-ts/app/api/seed/route.ts new file mode 100644 index 000000000..29b404ea6 --- /dev/null +++ b/examples/ai-chatbot-ts/app/api/seed/route.ts @@ -0,0 +1,85 @@ +import { timingSafeEqual } from "node:crypto" +import { getPostgresConnection } from "@alienplatform/sdk" +import { Client } from "pg" + +// The app's endpoint is public, so dropping the tables is gated on a secret the operator +// sets, and refuses to run when that secret is unset. Plain seeding is idempotent and needs +// no secret: it creates the tables if absent and inserts the demo rows only into an empty +// database, so a stray request can never destroy data. +function authorizedToReset(request: Request): boolean { + const expected = process.env.SEED_TOKEN + if (!expected) return false + const provided = request.headers.get("x-seed-token") ?? "" + const a = Buffer.from(provided) + const b = Buffer.from(expected) + // timingSafeEqual throws on a length mismatch, which would itself leak the length. + return a.length === b.length && timingSafeEqual(a, b) +} + +// Postgres is private (same-stack only), so it cannot be seeded from a laptop. +// This route runs inside the deployed app and seeds the demo data on demand: +// curl -X POST https:///api/seed +// To wipe and reseed: +// curl -X POST -H "x-seed-token: $SEED_TOKEN" 'https:///api/seed?reset=1' +export async function POST(request: Request) { + const reset = new URL(request.url).searchParams.get("reset") === "1" + if (reset && !authorizedToReset(request)) { + return Response.json({ error: "unauthorized" }, { status: 401 }) + } + + const conn = await getPostgresConnection("db") + // Field style + conn.ssl, NOT conn.connectionString (the sslmode footgun). + const client = new Client({ + host: conn.host, + port: conn.port, + database: conn.database, + user: conn.username, + password: conn.password, + ssl: conn.ssl, + }) + await client.connect() + try { + if (reset) { + await client.query("drop table if exists orders; drop table if exists customers;") + } + await client.query(` + create table if not exists customers ( + id serial primary key, name text, plan text, country text, mrr_usd int + ); + create table if not exists orders ( + id serial primary key, customer_id int references customers(id), + amount_usd int, status text, created date + ); + `) + // Only an empty database is seeded, so a repeated request is a no-op rather than a + // duplicate insert. + const existing = await client.query("select count(*)::int as n from customers") + if (existing.rows[0].n === 0) { + await client.query(` + insert into customers (name, plan, country, mrr_usd) values + ('Acme Corp','enterprise','US',4200), + ('Globex','enterprise','DE',3800), + ('Initech','pro','US',900), + ('Umbrella','enterprise','UK',5100), + ('Hooli','pro','IL',1200), + ('Stark Industries','enterprise','US',6400), + ('Wayne Enterprises','pro','US',1500), + ('Soylent','starter','FR',150); + `) + await client.query(` + insert into orders (customer_id, amount_usd, status, created) values + (1,1200,'paid','2026-05-02'),(1,800,'paid','2026-06-01'), + (2,3800,'paid','2026-06-03'),(4,5100,'paid','2026-06-05'), + (6,6400,'paid','2026-06-06'),(3,900,'refunded','2026-05-20'), + (5,1200,'paid','2026-06-10'),(7,1500,'pending','2026-06-12'), + (8,150,'paid','2026-06-14'),(6,2000,'paid','2026-06-20'); + `) + } + const summary = await client.query( + "select count(*)::int as customers, sum(mrr_usd)::int as total_mrr from customers", + ) + return Response.json({ seeded: true, ...summary.rows[0] }) + } finally { + await client.end() + } +} diff --git a/examples/ai-chatbot-ts/app/components/grain-background.tsx b/examples/ai-chatbot-ts/app/components/grain-background.tsx new file mode 100644 index 000000000..aaf174813 --- /dev/null +++ b/examples/ai-chatbot-ts/app/components/grain-background.tsx @@ -0,0 +1,39 @@ +"use client" + +import dynamic from "next/dynamic" + +// Alien-brand hero background: a green grain-gradient shader over near-black, +// with a radial-gradient fallback while the shader loads (or without WebGL). +const GrainGradient = dynamic( + () => import("@paper-design/shaders-react").then(mod => mod.GrainGradient), + { ssr: false, loading: () => }, +) + +export function GrainBackground() { + return ( +

+ + +
+ ) +} + +function Fallback() { + return ( +
+ ) +} diff --git a/examples/ai-chatbot-ts/app/components/message.tsx b/examples/ai-chatbot-ts/app/components/message.tsx new file mode 100644 index 000000000..7f6711e61 --- /dev/null +++ b/examples/ai-chatbot-ts/app/components/message.tsx @@ -0,0 +1,61 @@ +"use client" + +import type { UIMessage } from "ai" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import { QueryCard, type QueryInput, type QueryOutput } from "./query-card" + +// The generic UIMessage part type doesn't know this app's tool shapes; narrow +// the queryDatabase parts to what the server tool actually sends. +type QueryToolPart = { + type: "tool-queryDatabase" + toolCallId: string + state: "input-streaming" | "input-available" | "output-available" | "output-error" + input?: QueryInput + output?: QueryOutput + errorText?: string +} + +export function Message({ message }: { message: UIMessage }) { + if (message.role === "user") { + const text = message.parts.map(part => (part.type === "text" ? part.text : "")).join("") + return ( +
+
+ {text} +
+
+ ) + } + + return ( +
+ {message.parts.map((part, i) => { + const key = `${message.id}-${i}` + if (part.type === "text") { + return ( +
+ {part.text} +
+ ) + } + if (part.type === "tool-queryDatabase") { + const tool = part as unknown as QueryToolPart + return ( + + ) + } + return null + })} +
+ ) +} diff --git a/examples/ai-chatbot-ts/app/components/query-card.tsx b/examples/ai-chatbot-ts/app/components/query-card.tsx new file mode 100644 index 000000000..43db4b07d --- /dev/null +++ b/examples/ai-chatbot-ts/app/components/query-card.tsx @@ -0,0 +1,123 @@ +"use client" + +import { Spinner } from "./spinner" + +// A queryDatabase tool invocation: the SQL the model wrote and a preview of +// the rows it got back from the stack's private Postgres. + +const PREVIEW_ROWS = 5 + +export type QueryInput = { sql?: string } +export type QueryOutput = { + rowCount?: number | null + rows?: Record[] + error?: string +} + +export function QueryCard({ + state, + input, + output, + errorText, +}: { + state: "input-streaming" | "input-available" | "output-available" | "output-error" + input?: QueryInput + output?: QueryOutput + errorText?: string +}) { + const running = state === "input-streaming" || state === "input-available" + const failure = + state === "output-error" ? (errorText ?? "The query could not be run.") : output?.error + const rows = output?.rows ?? [] + const columns = rows.length > 0 ? Object.keys(rows[0]) : [] + // Right-align numeric columns so magnitudes line up, the way data tables read best. + const numeric = new Set(columns.filter(column => typeof rows[0]?.[column] === "number")) + + return ( +
+
+ + + {running ? "Querying the database" : failure ? "Query failed" : "Queried the database"} + + {running && } + {typeof output?.rowCount === "number" && ( + + {output.rowCount} {output.rowCount === 1 ? "row" : "rows"} + + )} +
+ + {input?.sql && ( +
+          {input.sql}
+        
+ )} + + {failure &&
{failure}
} + + {columns.length > 0 && ( +
+ + + + {columns.map(column => ( + + ))} + + + + {rows.slice(0, PREVIEW_ROWS).map((row, i) => ( + // Preview rows have no stable id; index keys are fine for a static slice. + // biome-ignore lint/suspicious/noArrayIndexKey: static preview + + {columns.map(column => ( + + ))} + + ))} + +
+ {column} +
+ {String(row[column] ?? "")} +
+ {rows.length > PREVIEW_ROWS && ( +
+ +{rows.length - PREVIEW_ROWS} more +
+ )} +
+ )} +
+ ) +} + +function DatabaseIcon() { + return ( + + ) +} diff --git a/examples/ai-chatbot-ts/app/components/spinner.tsx b/examples/ai-chatbot-ts/app/components/spinner.tsx new file mode 100644 index 000000000..1f771a0bf --- /dev/null +++ b/examples/ai-chatbot-ts/app/components/spinner.tsx @@ -0,0 +1,19 @@ +"use client" + +import { useEffect, useState } from "react" + +// Terminal-style braille spinner, the in-progress marker across Alien surfaces. +const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + +export function Spinner({ className }: { className?: string }) { + const [frame, setFrame] = useState(0) + useEffect(() => { + const timer = setInterval(() => setFrame(f => (f + 1) % FRAMES.length), 80) + return () => clearInterval(timer) + }, []) + return ( + + ) +} diff --git a/examples/ai-chatbot-ts/app/globals.css b/examples/ai-chatbot-ts/app/globals.css new file mode 100644 index 000000000..0382f37fa --- /dev/null +++ b/examples/ai-chatbot-ts/app/globals.css @@ -0,0 +1,16 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/typography"; + +/* Alien-brand dark theme tokens: near-black surfaces, + terminal-green brand, green-tinted landing text, borders for depth. */ +@theme { + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-brand: #5aff75; + --color-brand-200: #7dff93; + --color-brand-foreground: #021a06; + --color-landing-foreground: #d0e4d3; + --color-landing-foreground-200: #7eb78a; + --color-card: color-mix(in oklab, #27272a 55%, #000); + --color-edge: rgb(255 255 255 / 7.5%); +} diff --git a/examples/ai-chatbot-ts/app/layout.tsx b/examples/ai-chatbot-ts/app/layout.tsx new file mode 100644 index 000000000..4050f81de --- /dev/null +++ b/examples/ai-chatbot-ts/app/layout.tsx @@ -0,0 +1,18 @@ +import { GeistMono } from "geist/font/mono" +import { GeistSans } from "geist/font/sans" +import type { Metadata } from "next" +import type { ReactNode } from "react" +import "./globals.css" + +export const metadata: Metadata = { + title: "AI chatbot on Alien", + description: "A streaming chatbot that talks to cloud LLMs through the Alien AI gateway.", +} + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/examples/ai-chatbot-ts/app/page.tsx b/examples/ai-chatbot-ts/app/page.tsx new file mode 100644 index 000000000..7fb982a3e --- /dev/null +++ b/examples/ai-chatbot-ts/app/page.tsx @@ -0,0 +1,235 @@ +"use client" + +import { useChat } from "@ai-sdk/react" +import { useEffect, useRef, useState } from "react" +import { GrainBackground } from "./components/grain-background" +import { Message } from "./components/message" +import { Spinner } from "./components/spinner" + +// Matched to the /api/seed dataset so first-run questions land. +const SUGGESTIONS = [ + "How many enterprise customers do we have and what's their total MRR?", + "Who are our top 5 customers by MRR?", + "How many orders are pending, and what are they worth?", + "Break down our customers by country.", +] + +export default function Chat() { + const [input, setInput] = useState("") + const [models, setModels] = useState([]) + const [model, setModel] = useState("") + const [seedNote, setSeedNote] = useState("") + const { messages, sendMessage, status, stop, error, regenerate } = useChat() + const bottomRef = useRef(null) + const composerRef = useRef(null) + + const busy = status === "submitted" || status === "streaming" + + // Model ids come from the cloud's catalog, not hardcoded. + useEffect(() => { + fetch("/api/models") + .then(r => r.json()) + .then((d: { models: string[] }) => { + setModels(d.models) + if (d.models[0]) setModel(d.models[0]) + }) + .catch(() => {}) + }, []) + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every stream update + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [messages]) + + function ask(text: string) { + sendMessage({ text }, { body: { model } }) + composerRef.current?.focus() + } + + function submit() { + const text = input.trim() + if (!text || busy) return + ask(text) + setInput("") + } + + async function seed() { + try { + const r = await fetch("/api/seed", { method: "POST" }) + const d: { customers?: number } = await r.json() + setSeedNote(r.ok ? `✓ Seeded ${d.customers} customers` : "Seeding failed") + } catch { + setSeedNote("Seeding failed") + } + setTimeout(() => setSeedNote(""), 4000) + } + + return ( +
+ +
+
+ +
+
+ {messages.length === 0 ? ( +
+
+
+ LIVE + AI connected to a private Postgres +
+

+ Ask your data anything. +

+

+ Answers come from live SQL against the stack's private Postgres, with no + credentials in the app. +

+
+
+ {SUGGESTIONS.map(suggestion => ( + + ))} +
+
+ ) : ( +
+ {messages.map(message => ( + + ))} + {status === "submitted" && ( +
+ + Thinking +
+ )} + {error && ( +
+ Something went wrong.{" "} + +
+ )} +
+ )} +
+
+
+ +