diff --git a/CHANGELOG.md b/CHANGELOG.md index d05d18e..4197f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `azure` model provider for Azure Foundry / Azure OpenAI with Microsoft Entra ID + auth (managed identity, AKS workload identity, `az login`); no API key needed - Structured output support for the agentic loop - Harvest MCP integration example (`examples/harvest/`) with config and auth docs - Human-in-the-loop tool approval: `safety.approval` (`mode: none | all | selected` diff --git a/docs/specs/016_azure_entra_auth_instructions.md b/docs/specs/016_azure_entra_auth_instructions.md new file mode 100644 index 0000000..ef663e4 --- /dev/null +++ b/docs/specs/016_azure_entra_auth_instructions.md @@ -0,0 +1,28 @@ +# 016 — Azure Foundry with Entra ID auth (instructions) + +## Original request + +> We are running the configurable agent on Azure, now as a Container App, +> later in AKS. OpenAI key authentication is turned off. Let's first plan: +> what do we need to add to this project to be able to auth to Azure Foundry? + +Follow-ups during planning: + +> Currently the app is calling OpenAI directly. In Azure I see a +> `.openai.azure.com` URL for the OpenAI API. + +> Keep MCP as a separate step, but keep it in mind — we will probably need +> that later. + +> Write plan and instructions to add Entra / Entra token support, in the +> simplest way possible. + +## Context + +- Today the agent only authenticates with API keys from env vars + (`OPENAI_API_KEY`, …). The Foundry resource has key auth disabled, so the + agent must use Microsoft Entra ID tokens. +- Runtime identity: managed identity on Azure Container Apps now, AKS + Workload Identity later. +- `@ai-sdk/azure` (≥ 4.0) supports Entra natively via a `tokenProvider` + option; `@azure/identity` supplies the token. diff --git a/docs/specs/016_azure_entra_auth_plan.md b/docs/specs/016_azure_entra_auth_plan.md new file mode 100644 index 0000000..25d9455 --- /dev/null +++ b/docs/specs/016_azure_entra_auth_plan.md @@ -0,0 +1,127 @@ +# 016 — Azure Foundry with Entra ID auth (plan) + +## Principle + +Add one provider, `azure`, that always authenticates with Microsoft Entra ID. +No API keys and no custom auth code: `@ai-sdk/azure` sends the bearer token, +`@azure/identity` gets and refreshes it. The same code runs on Container +Apps, AKS and a dev laptop. + +## Why code is needed + +Without code changes, the only way to send an Entra token is a static token +in `OPENAI_API_KEY` with `provider: openai` and the Azure v1 `baseUrl`. The +`openai` provider sends it as `Authorization: Bearer`, which the Azure v1 +endpoint accepts. However, the token expires, and nothing in the process can +refresh it. A proxy that adds the token (APIM or a sidecar) adds an infra +component and moves the auth problem to the agent → proxy hop. Neither is fit +for production. + +## Smoke test before implementing + +Use the static-token path to check network, RBAC, the deployment name and +Responses API support, with no code changes: + +```sh +export OPENAI_API_KEY=$(az account get-access-token \ + --resource https://cognitiveservices.azure.com --query accessToken -o tsv) +# config: provider: openai, name: , +# baseUrl: https://.openai.azure.com/openai/v1 +CONFIG_PATH=./config.yaml pnpm dev serve & +curl -s 'localhost:3000/ready?deep=1' # {"status":"ok","probe":"passed"} +``` + +## Config + +```yaml +model: + provider: azure + name: # Foundry deployment name, not the model name + baseUrl: https://.openai.azure.com/openai +``` + +- `baseUrl` is the `/openai` prefix. The SDK appends `/v1/...` and + `?api-version=v1`. The latest `@ai-sdk/azure` also accepts this form, so + configs survive an SDK upgrade. + +- `baseUrl` is required for `azure` (schema refine with a clear error). +- No other new keys. The token scope is fixed to + `https://cognitiveservices.azure.com/.default`, the scope for + `*.openai.azure.com`. + +## Changes + +- **Dependencies:** `pnpm add @ai-sdk/azure@4.0.11 @azure/identity`. + `@ai-sdk/azure` 4.0.11 is the release that matches the installed + `@ai-sdk/openai` 4.0.11 and the pinned `@ai-sdk/provider` 4.0.3. Newer + releases bring a newer `@ai-sdk/provider` and fail typecheck. Upgrading all + `@ai-sdk/*` packages together is a separate task. +- **`src/config/schema.ts`:** add `azure` to `ModelProvider` and its error + message; refine `model` so `provider: azure` requires `baseUrl`. +- **`src/agent/model.ts`:** + - `case 'azure'` returns + `createAzure({ baseURL: cfg.baseUrl, tokenProvider })(cfg.name)`. This + uses the Responses API, the same as the current `openai` provider. + - `tokenProvider` is + `getBearerTokenProvider(new DefaultAzureCredential(), scope)`. It is + created lazily on first use and kept in a module-level variable, so the + token cache is shared by the main loop, compaction, tool summaries and + the `/ready` probe. + - `requiredEnvVarFor('azure')` returns `null`. There is no key to check. + `/ready?deep=1` checks the real path: token, then endpoint, then + deployment. +- **Credential selection** is done by `DefaultAzureCredential`, with no code + per environment: + - Container Apps: `ManagedIdentityCredential`. For a user-assigned + identity, set `AZURE_CLIENT_ID`. + - AKS: `WorkloadIdentityCredential`, using env vars injected by the + Workload Identity webhook. + - Local dev: `AzureCliCredential`, after `az login`. + - Optional in production: `AZURE_TOKEN_CREDENTIALS=prod` skips the dev + credentials, for faster and predictable startup. + +## Azure setup (documented, not code) + +- Give the Container App a managed identity. User-assigned is recommended + because it can be reused in AKS. +- Assign that identity the **Cognitive Services OpenAI User** role on the + Foundry / Azure OpenAI resource. +- AKS later: enable Workload Identity, add a federated credential on the same + identity for the pod's ServiceAccount, annotate the ServiceAccount with the + client ID, and label the pod `azure.workload.identity/use: "true"`. + +## Docs + +- `docs/user-guide.md`: + - Add `azure` to the provider lists. + - Add a short "Azure Foundry (Entra ID)" subsection covering config, the + role assignment, `AZURE_CLIENT_ID`, `AZURE_TOKEN_CREDENTIALS` and local + `az login`. + - Add the Azure env vars to the env table. + - Note that `/ready` has no key check for `azure`. + +## Tests + +- `tests/config.test.ts`: `provider: azure` is accepted with `baseUrl` and + rejected without it. +- `tests/model.test.ts`: + - Mock `@azure/identity` with a fake credential that returns a fixed token. + - Assert that `buildModel` returns an Azure model. + - Assert that a call through a stub `fetch` sends + `Authorization: Bearer `, sends no `api-key` header, and uses the + configured `baseUrl`. + - Assert that `requiredEnvVarFor('azure')` is `null`. +- `tests/server.test.ts`: `/ready` returns 200 for `azure` with no API-key + env vars set. + +## Out of scope / follow-ups + +- **MCP over HTTP with Entra.** HTTP MCP servers behind Entra will need a + per-request bearer token in `mcpTools[].headers`. When needed, reuse the + same `DefaultAzureCredential` with a per-server scope + (e.g. `api:///.default`). Specify this separately. +- **Non-OpenAI Foundry models** (Mistral, Llama, …) need Chat Completions + (`azure.chat(...)`) instead of Responses. Add an option when such a model + is actually used. +- **Claude on Foundry** is not covered by `@ai-sdk/azure`. +- **API-key auth for `azure`.** Not needed while key auth is disabled. diff --git a/docs/user-guide.md b/docs/user-guide.md index 0ca9cf1..c7c9ef6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -9,6 +9,7 @@ introduction and install steps, see the [project README](../README.md). - [Running](#running) - [Configuration](#configuration) - [Model and providers](#model-and-providers) + - [Azure Foundry (Entra ID)](#azure-foundry-entra-id) - [Prompt templating](#prompt-templating) - [Tools](#tools) - [Structured output](#structured-output) @@ -25,7 +26,7 @@ introduction and install steps, see the [project README](../README.md). ## Features - **One-file configuration** — prompt, model, tools, and safety knobs in YAML. -- **Any major provider** — Anthropic, OpenAI, Google, or any OpenAI-compatible +- **Any major provider** — Anthropic, OpenAI, Azure Foundry, Google, or any OpenAI-compatible endpoint (including local [ollama](https://ollama.com)). - **External tools via MCP** — connect any Model Context Protocol server over stdio or HTTP; the agent discovers and uses its tools. @@ -73,7 +74,7 @@ systemPrompt: | You are a helpful assistant... model: - provider: anthropic # anthropic | openai | google | ollama | openai-compatible + provider: anthropic # anthropic | openai | azure | google | ollama | openai-compatible name: claude-sonnet-4-6 temperature: 0.2 # topP, maxOutputTokens also supported @@ -112,10 +113,11 @@ output: ### Model and providers -Set `model.provider` to one of `anthropic`, `openai`, `google`, `ollama`, or -`openai-compatible`, and `model.name` to a model the provider offers. Each -hosted provider reads its API key from an environment variable (see -[Environment variables](#environment-variables)). +Set `model.provider` to one of `anthropic`, `openai`, `azure`, `google`, +`ollama`, or `openai-compatible`, and `model.name` to a model the provider +offers. Each hosted provider reads its API key from an environment variable +(see [Environment variables](#environment-variables)), except `azure`, which +uses Entra ID (see below). `model.baseUrl` works for every provider: for `ollama` and `openai-compatible` it points at your endpoint (these often need no API key); for the hosted @@ -124,6 +126,35 @@ API endpoint. See [Connecting to a local ollama](#connecting-to-a-local-ollama) for a Kubernetes tip. +#### Azure Foundry (Entra ID) + +`provider: azure` calls OpenAI models deployed in Azure Foundry / Azure +OpenAI and authenticates with Microsoft Entra ID tokens, not API keys. It works +when key auth is disabled on the resource. + +```yaml +model: + provider: azure + name: my-gpt-deployment # the deployment name, not the model name + baseUrl: https://.openai.azure.com/openai # required +``` + +The token comes from `DefaultAzureCredential` and is refreshed +automatically. It picks the identity from the environment: + +- **Azure Container Apps:** the app's managed identity. For a user-assigned + identity, set `AZURE_CLIENT_ID` to its client ID. +- **AKS:** Workload Identity. Federate the identity with the pod's + ServiceAccount and label the pod `azure.workload.identity/use: "true"`. The + webhook injects the env vars. +- **Local:** `az login`. + +The identity needs the **Cognitive Services OpenAI User** role on the +resource. In production, set `AZURE_TOKEN_CREDENTIALS=prod` so developer +credentials (Azure CLI and similar) are never tried. `/ready` has no key to +check for `azure`. Use `/ready?deep=1` to verify the token, endpoint and +deployment. + ### Prompt templating `systemPrompt` is a [Handlebars](https://handlebarsjs.com) template. These @@ -244,7 +275,7 @@ run — see [Resolving a tool approval](#resolving-a-tool-approval). | Route | Method | Purpose | |-------|--------|---------| | `/health` | GET | Liveness — returns 200 as soon as the process is up. | -| `/ready` | GET | Readiness — 200 when the config is loaded and the provider key is present. Add `?deep=1` to also make one tiny provider call that verifies the credentials, URL, and model actually work (503 on failure; the provider error appears in the server logs, not the response). Probe results are cached briefly, so frequent polling can't hammer the provider. | +| `/ready` | GET | Readiness — 200 when the config is loaded and the provider key is present (no key check for `azure`). Add `?deep=1` to also make one tiny provider call that verifies the credentials, URL, and model actually work (503 on failure; the provider error appears in the server logs, not the response). Probe results are cached briefly, so frequent polling can't hammer the provider. | | `/invoke` | POST | Run the agent and stream the result over SSE. Bodies larger than the configured limit are rejected with 413. | ### Request format @@ -399,6 +430,7 @@ Make sure ollama listens on all interfaces (`OLLAMA_HOST=0.0.0.0`). | `PORT` | `3000` | HTTP port. | | `LOG_LEVEL` | `info` | Log level (`trace`…`fatal`, or `silent`). | | `ANTHROPIC_API_KEY` · `OPENAI_API_KEY` · `GOOGLE_GENERATIVE_AI_API_KEY` | — | Provider credentials, per `model.provider`. | +| `AZURE_CLIENT_ID` · `AZURE_TOKEN_CREDENTIALS` | — | Entra ID for `provider: azure`: user-assigned managed identity client ID; `prod` to skip developer credentials. See [Azure Foundry (Entra ID)](#azure-foundry-entra-id). | | `OLLAMA_BASE_URL` · `OPENAI_BASE_URL` | provider default | Base URL for `ollama` / `openai-compatible` when `model.baseUrl` is unset. | | `TOOL_APPROVAL_SECRET` | — | Signs tool-approval requests. Required (server refuses to start without it) whenever approval is enabled. | | `MAX_REQUEST_BODY_BYTES` | `10485760` (10 MiB) | Maximum `/invoke` request body size; larger bodies get 413. | diff --git a/package.json b/package.json index 0849a6d..0a6fbb9 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,14 @@ }, "dependencies": { "@ai-sdk/anthropic": "^4.0.11", + "@ai-sdk/azure": "^4.0.11", "@ai-sdk/google": "^4.0.11", "@ai-sdk/mcp": "^2.0.10", "@ai-sdk/openai": "^4.0.11", "@ai-sdk/openai-compatible": "^3.0.7", "@ai-sdk/otel": "^1.0.19", "@ai-sdk/provider": "4.0.3", + "@azure/identity": "^4.14.0", "@hono/node-server": "^2.0.8", "@opentelemetry/api": "^1.9.1", "@opentelemetry/auto-instrumentations-node": "^0.78.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c6cb94..b5629ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@ai-sdk/anthropic': specifier: ^4.0.11 version: 4.0.11(zod@4.4.3) + '@ai-sdk/azure': + specifier: ^4.0.11 + version: 4.0.11(zod@4.4.3) '@ai-sdk/google': specifier: ^4.0.11 version: 4.0.11(zod@4.4.3) @@ -29,6 +32,9 @@ importers: '@ai-sdk/provider': specifier: 4.0.3 version: 4.0.3 + '@azure/identity': + specifier: ^4.14.0 + version: 4.14.0 '@hono/node-server': specifier: ^2.0.8 version: 2.0.8(hono@4.12.28) @@ -105,6 +111,18 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/azure@4.0.11': + resolution: {integrity: sha512-skqrm8QLHRsfgWx6V+sjYUOsJouNuuTsjKviedu5PB0iH5iz3G3D7zlZZwTXQDyNmHYvGLe0VUZ+R41xf7ToDA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/deepseek@3.0.7': + resolution: {integrity: sha512-XtY5eBUUE2fjcm1rprHowPVhh79knuBoiWzd+cI8ANf0hanOxRnHc8JpvC4KWMqoweGz4EOAnC3BR/fyGdAhhw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@4.0.15': resolution: {integrity: sha512-JHvgCU4SR6OdjOI+8rxIF3zhIboFAkQ1vEa+QOIdkK5VyME1ONx8SbgM2YtnSY1DOsCtUyhd+EG876pYpRjopg==} engines: {node: '>=22'} @@ -149,6 +167,58 @@ packages: resolution: {integrity: sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==} engines: {node: '>=22'} + '@azure/abort-controller@2.2.0': + resolution: {integrity: sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==} + engines: {node: '>=22.0.0'} + + '@azure/core-auth@1.11.0': + resolution: {integrity: sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==} + engines: {node: '>=22.0.0'} + + '@azure/core-client@1.11.1': + resolution: {integrity: sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-process@1.0.0': + resolution: {integrity: sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==} + engines: {node: '>=22.0.0'} + + '@azure/core-rest-pipeline@1.25.0': + resolution: {integrity: sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==} + engines: {node: '>=22.0.0'} + + '@azure/core-tracing@1.4.0': + resolution: {integrity: sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-util@1.14.0': + resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} + engines: {node: '>=22.0.0'} + + '@azure/identity@4.14.0': + resolution: {integrity: sha512-8utCLdTZn14CiAe0HW4JbtzUMsc0mdDwnjUvStFMrR4IxQP8S2eL9zVAdplpfORVJGYmidZElpeXRYjJQ4UyUw==} + engines: {node: '>=22.0.0'} + + '@azure/logger@1.4.0': + resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} + engines: {node: '>=22.0.0'} + + '@azure/msal-browser@5.25.1': + resolution: {integrity: sha512-NDW2/bz2Pz71EJAlhSw+jg1kpRIWtrzj0gCzcHuG1u7iLvSOHpUc5lICkDA9x0bbeityxTneiunSBluvS+/y4w==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.14.1': + resolution: {integrity: sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.14.2': + resolution: {integrity: sha512-HOBR0Gfo39TMrkxNG8k6cfjRkrRjXZc3JQs3ebMWQc9S4cihuknMYra3sgmzbZB752oQBqBN9+WF7c826p2vIQ==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@6.0.1': + resolution: {integrity: sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==} + engines: {node: '>=20'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -1207,6 +1277,10 @@ packages: cpu: [x64] os: [win32] + '@typespec/ts-http-runtime@0.3.9': + resolution: {integrity: sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==} + engines: {node: '>=22.0.0'} + '@vercel/oidc@3.2.0': resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} @@ -1309,6 +1383,13 @@ packages: brace-expansion@2.1.2: resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bundle-name@4.1.1: + resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1351,6 +1432,18 @@ packages: supports-color: optional: true + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1358,6 +1451,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1462,6 +1558,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -1470,10 +1570,28 @@ packages: resolution: {integrity: sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==} engines: {node: '>=18'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1504,6 +1622,16 @@ packages: json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1581,6 +1709,27 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -1639,6 +1788,10 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + open@11.0.4: + resolution: {integrity: sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==} + engines: {node: '>=20'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1705,6 +1858,10 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + powershell-utils@0.2.1: + resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} + engines: {node: '>=20'} + process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} @@ -1743,6 +1900,13 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -1963,6 +2127,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wsl-utils@1.0.1: + resolution: {integrity: sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==} + engines: {node: '>=20'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -1995,6 +2163,20 @@ snapshots: '@ai-sdk/provider-utils': 5.0.7(zod@4.4.3) zod: 4.4.3 + '@ai-sdk/azure@4.0.11(zod@4.4.3)': + dependencies: + '@ai-sdk/deepseek': 3.0.7(zod@4.4.3) + '@ai-sdk/openai': 4.0.11(zod@4.4.3) + '@ai-sdk/provider': 4.0.3 + '@ai-sdk/provider-utils': 5.0.7(zod@4.4.3) + zod: 4.4.3 + + '@ai-sdk/deepseek@3.0.7(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.3 + '@ai-sdk/provider-utils': 5.0.7(zod@4.4.3) + zod: 4.4.3 + '@ai-sdk/gateway@4.0.15(zod@4.4.3)': dependencies: '@ai-sdk/provider': 4.0.3 @@ -2047,6 +2229,93 @@ snapshots: dependencies: json-schema: 0.4.0 + '@azure/abort-controller@2.2.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.11.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.11.1': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-process@1.0.0': {} + + '@azure/core-rest-pipeline@1.25.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@typespec/ts-http-runtime': 0.3.9 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.4.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.14.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@typespec/ts-http-runtime': 0.3.9 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/identity@4.14.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-client': 1.11.1 + '@azure/core-process': 1.0.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@azure/msal-browser': 5.25.1 + '@azure/msal-node': 6.0.1 + open: 11.0.4 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/logger@1.4.0': + dependencies: + '@typespec/ts-http-runtime': 0.3.9 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/msal-browser@5.25.1': + dependencies: + '@azure/msal-common': 16.14.2 + + '@azure/msal-common@16.14.1': {} + + '@azure/msal-common@16.14.2': {} + + '@azure/msal-node@6.0.1': + dependencies: + '@azure/msal-common': 16.14.1 + jsonwebtoken: 9.0.3 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -3126,6 +3395,14 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@typespec/ts-http-runtime@0.3.9': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@vercel/oidc@3.2.0': {} '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': @@ -3233,6 +3510,12 @@ snapshots: dependencies: balanced-match: 1.0.2 + buffer-equal-constant-time@1.0.1: {} + + bundle-name@4.1.1: + dependencies: + run-applescript: 7.1.0 + chai@6.2.2: {} cjs-module-lexer@2.2.0: {} @@ -3265,10 +3548,23 @@ snapshots: dependencies: ms: 2.1.3 + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.1 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + detect-libc@2.1.2: {} eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -3388,6 +3684,13 @@ snapshots: html-escaper@2.0.2: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -3401,8 +3704,20 @@ snapshots: es-module-lexer: 2.3.0 module-details-from-path: 1.0.4 + is-docker@3.0.0: {} + is-fullwidth-code-point@3.0.0: {} + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -3434,6 +3749,30 @@ snapshots: json-schema@0.4.0: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + lightningcss-android-arm64@1.32.0: optional: true @@ -3485,6 +3824,20 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + long@5.3.2: {} lru-cache@10.4.3: {} @@ -3531,6 +3884,15 @@ snapshots: on-exit-leak-free@2.1.2: {} + open@11.0.4: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.2.1 + wsl-utils: 1.0.1 + package-json-from-dist@1.0.1: {} path-key@3.1.1: {} @@ -3596,6 +3958,8 @@ snapshots: dependencies: xtend: 4.0.2 + powershell-utils@0.2.1: {} + process-warning@5.0.0: {} protobufjs@7.6.5: @@ -3654,6 +4018,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 + run-applescript@7.1.0: {} + + safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} semver@7.8.5: {} @@ -3723,8 +4091,7 @@ snapshots: tinyrainbow@3.1.0: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.23.0: dependencies: @@ -3828,6 +4195,11 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + wsl-utils@1.0.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.2.1 + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/src/agent/model.ts b/src/agent/model.ts index 5fec45c..e1e7004 100644 --- a/src/agent/model.ts +++ b/src/agent/model.ts @@ -1,11 +1,19 @@ import { anthropic, createAnthropic } from '@ai-sdk/anthropic'; +import { createAzure } from '@ai-sdk/azure'; import { createGoogleGenerativeAI, google } from '@ai-sdk/google'; import { createOpenAI, openai } from '@ai-sdk/openai'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import { DefaultAzureCredential, getBearerTokenProvider } from '@azure/identity'; import { generateText, type LanguageModel } from 'ai'; import type { AgentConfig } from '../config/schema.js'; import { errorMessage } from '../util.js'; +const AZURE_SCOPE = 'https://cognitiveservices.azure.com/.default'; + +// One Entra token provider per process, so every model built (main loop, +// compaction, tool summaries, /ready probe) shares its token cache. +let azureTokenProvider: (() => Promise) | undefined; + export function buildModel(cfg: AgentConfig['model']): LanguageModel { switch (cfg.provider) { case 'anthropic': @@ -14,6 +22,11 @@ export function buildModel(cfg: AgentConfig['model']): LanguageModel { : anthropic(cfg.name); case 'openai': return cfg.baseUrl ? createOpenAI({ baseURL: cfg.baseUrl })(cfg.name) : openai(cfg.name); + case 'azure': + // Entra ID only: DefaultAzureCredential picks managed identity, AKS + // workload identity, or `az login` from the environment. + azureTokenProvider ??= getBearerTokenProvider(new DefaultAzureCredential(), AZURE_SCOPE); + return createAzure({ baseURL: cfg.baseUrl, tokenProvider: azureTokenProvider })(cfg.name); case 'google': return cfg.baseUrl ? createGoogleGenerativeAI({ baseURL: cfg.baseUrl })(cfg.name) @@ -70,6 +83,8 @@ export function requiredEnvVarFor(provider: AgentConfig['model']['provider']): s return 'ANTHROPIC_API_KEY'; case 'openai': return 'OPENAI_API_KEY'; + case 'azure': + return null; case 'google': return 'GOOGLE_GENERATIVE_AI_API_KEY'; case 'ollama': diff --git a/src/config/schema.ts b/src/config/schema.ts index 0e33c7d..21eb094 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,8 +1,11 @@ import { z } from 'zod'; export const ModelProvider = z.enum( - ['anthropic', 'openai', 'google', 'ollama', 'openai-compatible'], - { error: 'model.provider must be one of: anthropic, openai, google, ollama, openai-compatible' }, + ['anthropic', 'openai', 'azure', 'google', 'ollama', 'openai-compatible'], + { + error: + 'model.provider must be one of: anthropic, openai, azure, google, ollama, openai-compatible', + }, ); export type ModelProvider = z.infer; @@ -37,26 +40,31 @@ export const AgentConfigSchema = z .object({ systemPrompt: z.string().min(1, 'systemPrompt must not be empty'), promptVars: z.record(z.string(), z.unknown()).optional(), - model: z.strictObject({ - provider: ModelProvider, - name: z.string().min(1, 'model.name must not be empty'), - baseUrl: z.url('model.baseUrl must be a valid URL').optional(), - temperature: z - .number() - .min(0, 'model.temperature must be between 0 and 2') - .max(2, 'model.temperature must be between 0 and 2') - .optional(), - topP: z - .number() - .min(0, 'model.topP must be between 0 and 1') - .max(1, 'model.topP must be between 0 and 1') - .optional(), - maxOutputTokens: z - .number() - .int('model.maxOutputTokens must be a positive integer') - .positive('model.maxOutputTokens must be a positive integer') - .optional(), - }), + model: z + .strictObject({ + provider: ModelProvider, + name: z.string().min(1, 'model.name must not be empty'), + baseUrl: z.url('model.baseUrl must be a valid URL').optional(), + temperature: z + .number() + .min(0, 'model.temperature must be between 0 and 2') + .max(2, 'model.temperature must be between 0 and 2') + .optional(), + topP: z + .number() + .min(0, 'model.topP must be between 0 and 1') + .max(1, 'model.topP must be between 0 and 1') + .optional(), + maxOutputTokens: z + .number() + .int('model.maxOutputTokens must be a positive integer') + .positive('model.maxOutputTokens must be a positive integer') + .optional(), + }) + .refine((m) => m.provider !== 'azure' || m.baseUrl, { + error: 'model.baseUrl is required for provider azure', + path: ['baseUrl'], + }), agent: z .strictObject({ maxSteps: z diff --git a/tests/config.test.ts b/tests/config.test.ts index e854894..47eb528 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -226,6 +226,33 @@ model: expect(cfg.model.baseUrl).toBe('http://localhost:11434/v1'); }); + it('accepts azure provider with baseUrl', () => { + const path = write( + 'config.yaml', + `systemPrompt: "be helpful" +model: + provider: azure + name: gpt-5-deployment + baseUrl: https://my-res.openai.azure.com/openai +`, + ); + const cfg = loadConfig(path); + expect(cfg.model.provider).toBe('azure'); + expect(cfg.model.baseUrl).toBe('https://my-res.openai.azure.com/openai'); + }); + + it('rejects azure provider without baseUrl', () => { + const path = write( + 'config.yaml', + `systemPrompt: "be helpful" +model: + provider: azure + name: gpt-5-deployment +`, + ); + expect(() => loadConfig(path)).toThrow(/baseUrl is required for provider azure/); + }); + it('rejects unknown keys in nested objects (typo protection for safety limits)', () => { const path = write( 'config.yaml', diff --git a/tests/model.test.ts b/tests/model.test.ts index 20f42ee..c34a680 100644 --- a/tests/model.test.ts +++ b/tests/model.test.ts @@ -1,9 +1,45 @@ -import { describe, expect, it } from 'vitest'; -import { buildModel } from '../src/agent/model.js'; +import { generateText } from 'ai'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildModel, requiredEnvVarFor } from '../src/agent/model.js'; import type { AgentConfig } from '../src/config/schema.js'; +// Fake Entra credential: no network, fixed token. +vi.mock('@azure/identity', () => ({ + DefaultAzureCredential: class {}, + getBearerTokenProvider: () => async () => 'entra-token', +})); + type ModelConfig = AgentConfig['model']; +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('buildModel — azure provider', () => { + it('sends the Entra token as a bearer header to the configured baseUrl', async () => { + const fetchMock = vi.fn(async () => new Response('{}', { status: 500 })); + vi.stubGlobal('fetch', fetchMock); + const model = buildModel({ + provider: 'azure', + name: 'gpt-5-deployment', + baseUrl: 'https://my-res.openai.azure.com/openai', + }); + + await expect(generateText({ model, prompt: 'ping', maxRetries: 0 })).rejects.toThrow(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://my-res.openai.azure.com/openai/v1/responses?api-version=v1'); + const headers = new Headers(init.headers); + expect(headers.get('authorization')).toBe('Bearer entra-token'); + expect(headers.get('api-key')).toBeNull(); + }); + + it('needs no API key env var', () => { + expect(requiredEnvVarFor('azure')).toBeNull(); + }); +}); + describe('buildModel — openai-compatible provider', () => { it('constructs a model using the configured baseUrl', () => { const cfg: ModelConfig = { diff --git a/tests/server.test.ts b/tests/server.test.ts index 5d59f61..09dd73b 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -85,6 +85,22 @@ describe('GET /ready', () => { expect(await res.json()).toEqual({ status: 'ok' }); }); + it('returns ok for azure without any API key env var (Entra auth)', async () => { + vi.stubEnv('AZURE_API_KEY', ''); + vi.stubEnv('OPENAI_API_KEY', ''); + const config = baseConfig({ + model: { + provider: 'azure', + name: 'gpt-5-deployment', + baseUrl: 'https://my-res.openai.azure.com/openai', + }, + }); + const app = buildServer(config, { tools: {} }); + const res = await app.request('/ready'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'ok' }); + }); + it('deep probe failure returns 503 without leaking the provider error', async () => { // Unreachable baseUrl: the probe fails fast with a connection error whose // message names the endpoint — that detail must stay out of the response.