diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 5fcc79f86e..1f7afda05b 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -159,5 +159,6 @@ jobs: - name: Clean up Keys run: | node -e "try{require('fs').unlinkSync('./.env');}catch(e){}" + node -e "try{require('fs').unlinkSync('./config.local.yaml');}catch(e){}" working-directory: ts if: always() diff --git a/ts/.gitignore b/ts/.gitignore index 1b41132f9b..f5813553e3 100644 --- a/ts/.gitignore +++ b/ts/.gitignore @@ -21,4 +21,12 @@ examples/schemaStudio/output/ # Per-subscription inventory snapshot written by inventoryEndpoints.mjs. # Contains actual account names, endpoints, and subdomain names — never commit. -tools/scripts/pools.inventory.json \ No newline at end of file +tools/scripts/pools.inventory.json + +# Per-developer YAML config overrides loaded by @typeagent/config. +# Holds local endpoints, deployment names, and (in Phase 1) secrets. +# Never commit. +config.local.yaml + +# Legacy .env files — deprecated, will stop working September 2026. +.env \ No newline at end of file diff --git a/ts/CLAUDE.md b/ts/CLAUDE.md index c7982a8fc8..3c359770d9 100644 --- a/ts/CLAUDE.md +++ b/ts/CLAUDE.md @@ -120,6 +120,6 @@ Agents implement `AppAgent` from `@typeagent/agent-sdk`: ### Environment - Requires **Node ≥22**, **pnpm ≥10** -- API keys go in `ts/.env` (Azure OpenAI or OpenAI endpoints) +- API keys go in `ts/config.local.yaml` (see `config.sample.yaml` for reference). Legacy `.env` is still supported but deprecated. - User data stored in `~/.typeagent/` - Tracing via the `debug` package — enable with `DEBUG=typeagent:*` env var diff --git a/ts/README.md b/ts/README.md index bcdf9cf421..b10c922149 100644 --- a/ts/README.md +++ b/ts/README.md @@ -49,30 +49,35 @@ If you want to use a local whisper service for voice input in the [TypeAgent She ### Service Keys -Multiple services are required to run the scenarios. Put the necessary keys in the `.env` file at this directory (TypeAgent repo's `./ts` directory). For more information standing up your own Azure OpenAI service endpoint, [continue here](https://azure.microsoft.com/en-us/products/ai-services/openai-service?msockid=03598722967c6ae20c3f93af97c66bd7). - -Here is an example of the minimal `.env` file targeting Azure: - -``` -AZURE_OPENAI_API_KEY= -AZURE_OPENAI_ENDPOINT= -AZURE_OPENAI_RESPONSE_FORMAT=1 - -AZURE_OPENAI_API_KEY_EMBEDDING= -AZURE_OPENAI_ENDPOINT_EMBEDDING= + deployments: + default: + endpoint: + responseFormat: true + endpoints: + embedding: + endpoint: ``` -Here is an example of the minimal `.env` file targeting OpenAI: - -``` -OPENAI_ORGANIZATION= -OPENAI_API_KEY= -OPENAI_ENDPOINT=https://api.openai.com/v1/chat/completions -OPENAI_MODEL=gpt-4o -OPENAI_RESPONSE_FORMAT=1 - -OPENAI_ENDPOINT_EMBEDDING=https://api.openai.com/v1/embeddings -OPENAI_MODEL_EMBEDDING=text-embedding-ada-002 +Here is an example of the minimal `config.local.yaml` targeting OpenAI: + +```yaml +openAI: + default: + organization: + apiKey: + endpoint: https://api.openai.com/v1/chat/completions + model: gpt-4o + responseFormat: true + embedding: + endpoint: https://api.openai.com/v1/embeddings + model: text-embedding-ada-002 ``` The follow set of functionality will need the services keys. Please read the links for details about the variables needed. It is possible to use "keyless" configuration for some APIs. See [Keyless API Access](#keyless-api-access) below. @@ -117,12 +122,12 @@ To setup: To update keys on the key vault: -- Add or change the values in the `.env` file +- Add or change the values in `config.local.yaml` (or `.env` for legacy setups) - Add new keys name in `tools/scripts/getKeys.config.json` - Run `npm run getKeys -- push [--vault ]`. (If the `--vault` option is omitted, the default from vault name in `tools/scripts/getKeys.config.json` is used.) - Check in the changes to `tools/scripts/getKeys.config.json` -To get the required config and keys saved to the `.env` file under the `ts` folder: +To get the required config and keys saved to `config.local.yaml` under the `ts` folder: - Run `npm run getKeys [--vault ]` at the root to pull secret from the key vault with ``. (If the `--vault` option is omitted, the default from vault name in `tools/scripts/getKeys.config.json` is used.) @@ -134,7 +139,7 @@ For additional security, it is possible to run a subset of the TypeAgent endpoin In order to use keyless access you must also configure your services to use [RBAC](https://learn.microsoft.com/en-us/azure/role-based-access-control/overview) and assign users access to the correct roles for each endpoint. Please see the tables above to determine keyless endpoint support. -After configuring your service, modify the .env file and specify `identity` as the key value instead of using keys in the examples provided. To authenticate at runtime, make sure you have installed [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest) and logged in with the account that has access to the models using `az login`. Make sure choose the subscription where your services are as the default. +After configuring your service, modify `config.local.yaml` and specify `identity` as the `defaultAuth` value instead of using keys in the examples provided. To authenticate at runtime, make sure you have installed [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest) and logged in with the account that has access to the models using `az login`. Make sure choose the subscription where your services are as the default. Alternatively, TypeAgent make use of the Azure SDK's [DefaultAzureCredential](https://learn.microsoft.com/en-us/javascript/api/%40azure/identity/defaultazurecredential?view=azure-node-latest) which provide other methods to authenticate at runtime. Follow those instructions to provide keyless access to the services. @@ -215,7 +220,7 @@ All commands run from the `ts/` directory using `pnpm`. ```bash pnpm run test:local # Unit tests (*.spec.ts) — no API keys required -pnpm run test:live # Integration tests (*.test.ts) — requires API keys in ts/.env +pnpm run test:live # Integration tests (*.test.ts) — requires API keys in ts/config.local.yaml pnpm run test # Both local + live + shell tests ``` @@ -313,7 +318,7 @@ Search the code base with '"typeagent:' will give all the traces available. ### Logging -TypeAgent does not collect telemetry by default. Developer can enable logging to a mongodb for internal debugging purpose by providing a mongodb connection string with the `MONGODB_CONNECTION_STRING` variable in the `.env` file. +TypeAgent does not collect telemetry by default. Developer can enable logging to a mongodb for internal debugging purpose by providing a mongodb connection string with the `MONGODB_CONNECTION_STRING` variable in `config.local.yaml` (under `storage.mongo.connectionString`) or the legacy `.env` file. ### Experiment with Local LLM via Ollama diff --git a/ts/config.defaults.yaml b/ts/config.defaults.yaml new file mode 100644 index 0000000000..0bfb299ea2 --- /dev/null +++ b/ts/config.defaults.yaml @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# TypeAgent default configuration. +# See config.sample.yaml for all available options. + +azureOpenAI: + defaultAuth: identity + maxConcurrency: 4 + maxTimeoutMs: 120000 + maxRetryAttempts: 3 + responseFormat: true + +reasoning: + timeoutMs: 1200000 + copilotModel: claude-sonnet-4.5 diff --git a/ts/config.sample.yaml b/ts/config.sample.yaml new file mode 100644 index 0000000000..ac7c74174d --- /dev/null +++ b/ts/config.sample.yaml @@ -0,0 +1,243 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# ────────────────────────────────────────────────────────────────────── +# TypeAgent — Sample Configuration +# ────────────────────────────────────────────────────────────────────── +# +# Copy this file to `config.local.yaml` (gitignored) and fill in your +# values. Only the sections you need are required — everything is +# optional except `azureOpenAI` which has sensible defaults in +# `config.defaults.yaml`. +# +# Precedence (lowest → highest): +# .env file → config.defaults.yaml → Key Vault → config.local.yaml → process.env +# +# See packages/config/README.md for full documentation. +# ────────────────────────────────────────────────────────────────────── + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ Azure OpenAI ║ +# ╚═══════════════════════════════════════════════════════════════════╝ +# +# Core AI model configuration. Auth can be "identity" (managed +# identity / DefaultAzureCredential) or an API key string. +# +# `defaultCapacity` cascades: section → deployment → endpoint. +# Endpoints inherit from deployment, deployments inherit from section. + +azureOpenAI: + defaultAuth: identity # "identity" or an API key string + defaultCapacity: 150 # TPM capacity shared by all endpoints + maxConcurrency: 40 # max parallel requests to Azure OpenAI + maxTimeoutMs: 60000 # per-request timeout in milliseconds + maxRetryAttempts: 3 # retries on transient failures + responseFormat: true # send response_format: json_object + + # Named model deployments. Each deployment has one or more regional + # endpoints that form a routing pool. + deployments: + gpt_4_o: + # Per-deployment defaultCapacity overrides the section-level value. + # defaultCapacity: 200 + endpoints: + # PTU (provisioned throughput) endpoints get priority 1 by default. + - endpoint: https://my-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2025-01-01-preview + mode: PTU + capacity: 300 + + # PAYG endpoints get priority 2 by default. Capacity is optional. + - endpoint: https://my-resource-eastus.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2025-01-01-preview + capacity: 450 + + # Region is auto-derived from the hostname (e.g. "eastus" from + # "my-resource-eastus"). Override when the hostname is ambiguous: + - endpoint: https://custom-resource.cognitiveservices.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2025-01-01-preview + region: eastus2 + + # Use `capacity: null` to explicitly opt out of capacity + # inheritance for this endpoint. + - endpoint: https://my-resource-france.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2025-01-01-preview + capacity: null + + # Per-endpoint auth override (uses an API key instead of identity): + - endpoint: https://partner-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2025-01-01-preview + auth: sk-abc123... + + gpt_4_o_mini: + endpoints: + - endpoint: https://my-resource-eastus.openai.azure.com/openai/deployments/gpt-4o-mini/chat/completions?api-version=2025-01-01-preview + capacity: 2000 + - endpoint: https://my-resource-westus.openai.azure.com/openai/deployments/gpt-4o-mini/chat/completions?api-version=2025-01-01-preview + + embedding: + defaultCapacity: 120 + endpoints: + - endpoint: https://my-resource-eastus.openai.azure.com/openai/deployments/ada-002/embeddings?api-version=2023-05-15 + capacity: 115 + - endpoint: https://my-resource-westus.openai.azure.com/openai/deployments/ada-002/embeddings?api-version=2023-05-15 + + gpt_image_1_5: + defaultCapacity: 30 + endpoints: + - endpoint: https://my-resource.openai.azure.com/openai/deployments/gpt-image-1.5/images/generations?api-version=2025-04-01-preview + + # Shorthand: a deployment with a single endpoint can be a bare list. + gpt_v: + - endpoint: https://my-resource.openai.azure.com/openai/deployments/gpt-v/chat/completions?api-version=2025-01-01-preview + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ OpenAI (non-Azure) ║ +# ╚═══════════════════════════════════════════════════════════════════╝ +# +# Direct OpenAI API access. The `local` sub-section targets Ollama +# or any local OpenAI-compatible server. + +openAI: + apiKey: sk-proj-... + endpoint: https://api.openai.com/v1/chat/completions + endpointEmbedding: https://api.openai.com/v1/embeddings + model: gpt-4o + modelEmbedding: text-embedding-3-small + organization: org-... + responseFormat: true + maxConcurrency: 4 + maxTimeoutMs: 60000 + maxRetryAttempts: 3 + + # Ollama / local OpenAI-compatible endpoint. + # Also emits OLLAMA_ENDPOINT for backward compatibility. + local: + apiKey: None # Ollama doesn't require a key + endpoint: http://localhost:11434/v1/chat/completions + model: phi3 + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ Azure Speech SDK ║ +# ╚═══════════════════════════════════════════════════════════════════╝ +# +# Voice input / speech-to-text. Auth is "identity" or a subscription +# key string. + +speech: + auth: identity + region: westus + endpoint: /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/ + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ Microsoft Graph ║ +# ╚═══════════════════════════════════════════════════════════════════╝ +# +# Used by the calendar and email agents for M365 integration. + +msGraph: + clientId: 00000000-0000-0000-0000-000000000000 + clientSecret: your-client-secret + tenantId: 00000000-0000-0000-0000-000000000000 + # Optional — for username/password (ROPC) auth flow: + # username: user@contoso.com + # password: your-password + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ Google Calendar ║ +# ╚═══════════════════════════════════════════════════════════════════╝ +# +# OAuth credentials for Google Calendar integration. + +googleCalendar: + clientId: 123456789-abc.apps.googleusercontent.com + clientSecret: GOCSPX-... + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ Spotify ║ +# ╚═══════════════════════════════════════════════════════════════════╝ +# +# Spotify app credentials for the player agent. + +spotify: + clientId: your-spotify-client-id + clientSecret: your-spotify-client-secret + port: 9998 # local callback port for OAuth flow + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ Azure AI Foundry / Bing with Grounding ║ +# ╚═══════════════════════════════════════════════════════════════════╝ +# +# Azure AI Foundry agent IDs and Bing-with-Grounding endpoints. + +azureFoundry: + bingEndpoint: https://my-resource.services.ai.azure.com/api/projects/my-project + bingAgentId: asst_xxxxxxxxxxxxxxxxxxxxxxxx + bingUrlResolutionAgentId: asst_xxxxxxxxxxxxxxxxxxxxxxxx + bingUrlResolutionConnectionId: /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/ + validatorAgentId: asst_xxxxxxxxxxxxxxxxxxxxxxxx + aliasKeywordExtractorAgentId: asst_xxxxxxxxxxxxxxxxxxxxxxxx + openPhraseGeneratorAgentId: asst_xxxxxxxxxxxxxxxxxxxxxxxx + httpEndpointLogicAppConnectionId: /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/ + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ Storage ║ +# ╚═══════════════════════════════════════════════════════════════════╝ +# +# Backends for session persistence, telemetry, and search indexes. + +storage: + # Azure Blob Storage (session persistence). + azure: + account: mystorageaccount + container: sessions + + # AWS S3 (alternative session storage). + aws: + bucketName: my-typeagent-bucket + region: us-east-1 + accessKeyId: AKIA... + secretAccessKey: wJalr... + + # Databases (telemetry sinks). + database: + cosmosDbConnectionString: https://my-cosmos-db.documents.azure.com:443/ + mongoDbConnectionString: mongodb+srv://user:pass@cluster.mongodb.net/telemetrydb + + # Elasticsearch (memory provider). + elastic: + apiKey: your-elastic-api-key + uri: https://my-cluster.es.us-east-1.aws.elastic.cloud + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ Azure Maps ║ +# ╚═══════════════════════════════════════════════════════════════════╝ + +maps: + clientId: 00000000-0000-0000-0000-000000000000 + endpoint: https://atlas.microsoft.com/ + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ Wikipedia ║ +# ╚═══════════════════════════════════════════════════════════════════╝ + +wikipedia: + clientId: your-wikimedia-client-id + clientSecret: your-wikimedia-client-secret + endpoint: https://api.wikimedia.org/core/v1/wikipedia/ + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ Reasoning ║ +# ╚═══════════════════════════════════════════════════════════════════╝ +# +# Tuning knobs for the reasoning loop (Claude / Copilot backends). + +reasoning: + timeoutMs: 1200000 # reasoning-loop timeout (0 = disabled) + copilotModel: claude-sonnet-4.5 # override default Copilot reasoning model + +# ╔═══════════════════════════════════════════════════════════════════╗ +# ║ Key Vault ║ +# ╚═══════════════════════════════════════════════════════════════════╝ +# +# Azure Key Vault name for automatic secret retrieval at startup. +# When set, server entry points call loadConfig({ keyVault: {} }) and +# the vault name is auto-discovered from this field. + +vault: + shared: aisystems diff --git a/ts/examples/chat/package.json b/ts/examples/chat/package.json index df0cda2de3..7b05d95240 100644 --- a/ts/examples/chat/package.json +++ b/ts/examples/chat/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@azure/search-documents": "12.1.0", + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "better-sqlite3": "12.6.2", "chalk": "^5.4.1", diff --git a/ts/examples/chat/src/main.ts b/ts/examples/chat/src/main.ts index 8cbb85f857..b5c31ec5b8 100644 --- a/ts/examples/chat/src/main.ts +++ b/ts/examples/chat/src/main.ts @@ -1,14 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import { runCodeChat } from "./codeChat/codeChat.js"; import { runKnowledgeProcessorCommands } from "./knowledgeProc/knowledgeProcessorMemory.js"; import { runCodeMemoryCommands } from "./codeChat/codeMemory.js"; import { runKnowproMemory } from "./memory/knowproMemory.js"; -const envPath = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +loadConfigSync(); let areaName = process.argv[2]; if (areaName) { diff --git a/ts/examples/classify/package.json b/ts/examples/classify/package.json index 649e9dc466..5cd6873864 100644 --- a/ts/examples/classify/package.json +++ b/ts/examples/classify/package.json @@ -21,6 +21,7 @@ "tsc": "tsc -p src" }, "dependencies": { + "@typeagent/config": "workspace:*", "chalk": "^5.4.1", "copyfiles": "^2.4.1", "dotenv": "^16.3.1", diff --git a/ts/examples/classify/src/main.ts b/ts/examples/classify/src/main.ts index 7991533d5a..76f252dbfe 100644 --- a/ts/examples/classify/src/main.ts +++ b/ts/examples/classify/src/main.ts @@ -1,13 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import { createLanguageModel } from "typechat"; import { createTextClassifier, TextClassifier } from "typeagent"; // Adjust the import path accordingly import { strict as assert } from "assert"; -const envPath = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +loadConfigSync(); const model = createLanguageModel(process.env); diff --git a/ts/examples/commandHistogram/package.json b/ts/examples/commandHistogram/package.json index 379ff12d3a..6360327716 100644 --- a/ts/examples/commandHistogram/package.json +++ b/ts/examples/commandHistogram/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@typeagent/common-utils": "workspace:*", + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "chalk": "^5.4.1", "code-processor": "workspace:*", diff --git a/ts/examples/commandHistogram/src/main.ts b/ts/examples/commandHistogram/src/main.ts index 60cd839892..12ed707b7b 100644 --- a/ts/examples/commandHistogram/src/main.ts +++ b/ts/examples/commandHistogram/src/main.ts @@ -1,12 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import { MongoClient } from "mongodb"; -// Load environment variables from .env file -const envPath = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +loadConfigSync(); // Run database query to get all issued commands const query = { diff --git a/ts/examples/docuProc/package.json b/ts/examples/docuProc/package.json index 211398ec95..66113a2a8f 100644 --- a/ts/examples/docuProc/package.json +++ b/ts/examples/docuProc/package.json @@ -25,6 +25,7 @@ "tsc": "tsc -p src" }, "dependencies": { + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "chalk": "^5.4.1", "cheerio": "1.0.0-rc.12", diff --git a/ts/examples/docuProc/src/main.ts b/ts/examples/docuProc/src/main.ts index 6bc339b297..7cca397754 100644 --- a/ts/examples/docuProc/src/main.ts +++ b/ts/examples/docuProc/src/main.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. // System imports -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; @@ -16,9 +16,7 @@ import { ChunkyIndex } from "./pdfChunkyIndex.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -// Load env vars (including secrets) from .env -const envPath = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +loadConfigSync(); // Files will be loaded from the data folder with `-` argument const dataFolder = path.join(__dirname, "data"); diff --git a/ts/examples/mcpMemory/package.json b/ts/examples/mcpMemory/package.json index 5a401515a9..82038d9cd8 100644 --- a/ts/examples/mcpMemory/package.json +++ b/ts/examples/mcpMemory/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", + "@typeagent/config": "workspace:*", "conversation-memory": "workspace:*", "dotenv": "^16.3.1", "examples-lib": "workspace:*", diff --git a/ts/examples/mcpMemory/src/server.ts b/ts/examples/mcpMemory/src/server.ts index 38bc370baa..0fd26cd41c 100644 --- a/ts/examples/mcpMemory/src/server.ts +++ b/ts/examples/mcpMemory/src/server.ts @@ -3,10 +3,9 @@ import { ensureDir } from "typeagent"; import { MemoryServer } from "./memoryServer.js"; -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; -const envPath = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +loadConfigSync(); console.log("Starting Memory Server"); diff --git a/ts/examples/memoryProviders/package.json b/ts/examples/memoryProviders/package.json index 7bdc2ff53f..7cc7ad863c 100644 --- a/ts/examples/memoryProviders/package.json +++ b/ts/examples/memoryProviders/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@elastic/elasticsearch": "^9.3.4", + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "better-sqlite3": "12.6.2", "debug": "^4.4.0", diff --git a/ts/examples/memoryProviders/test/sqlite.textTable.spec.ts b/ts/examples/memoryProviders/test/sqlite.textTable.spec.ts index 87709d4d2b..40a581c515 100644 --- a/ts/examples/memoryProviders/test/sqlite.textTable.spec.ts +++ b/ts/examples/memoryProviders/test/sqlite.textTable.spec.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import * as sqlite from "better-sqlite3"; import { AssignedId, createDatabase } from "../src/sqlite/common.js"; diff --git a/ts/examples/playground/package.json b/ts/examples/playground/package.json index 50a47454b1..e82510d071 100644 --- a/ts/examples/playground/package.json +++ b/ts/examples/playground/package.json @@ -21,6 +21,7 @@ "tsc": "tsc -p src" }, "dependencies": { + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "copyfiles": "^2.4.1", "dotenv": "^16.3.1", diff --git a/ts/examples/playground/src/main.ts b/ts/examples/playground/src/main.ts index 97f4e03a30..18dc10d424 100644 --- a/ts/examples/playground/src/main.ts +++ b/ts/examples/playground/src/main.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import { ChatModelWithStreaming, CompletionSettings, openai } from "aiclient"; import { CommandHandler, @@ -29,8 +29,7 @@ import { createPromptLogger } from "telemetry"; const promptLogger = createPromptLogger(); -const envPath = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +loadConfigSync(); type ChatResponse = { message: string; diff --git a/ts/examples/schemaStudio/package.json b/ts/examples/schemaStudio/package.json index 7635c4ae23..a60aebf746 100644 --- a/ts/examples/schemaStudio/package.json +++ b/ts/examples/schemaStudio/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@typeagent/action-schema": "workspace:*", + "@typeagent/config": "workspace:*", "agent-cache": "workspace:*", "agent-dispatcher": "workspace:*", "aiclient": "workspace:*", diff --git a/ts/examples/schemaStudio/src/main.ts b/ts/examples/schemaStudio/src/main.ts index 7369d972ea..bf44d0a877 100644 --- a/ts/examples/schemaStudio/src/main.ts +++ b/ts/examples/schemaStudio/src/main.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -import findConfig from "find-config"; +import { loadConfigSync } from "@typeagent/config"; import { CommandHandler, CommandMetadata, @@ -37,10 +36,7 @@ import { createSettingsSchemaCommand } from "./schemaCommands.js"; import { createBatchPopulateCommand } from "./batchPopulateCommand.js"; import { createMergeCacheCommand } from "./mergeCacheCommand.js"; -const envPath = findConfig(".env"); -if (envPath) { - dotenv.config({ path: envPath }); -} +loadConfigSync(); interface VariationOptions extends VariationSettings { depth: number; diff --git a/ts/examples/searchActionTest/package.json b/ts/examples/searchActionTest/package.json index 33ef02faad..9ec482e97a 100644 --- a/ts/examples/searchActionTest/package.json +++ b/ts/examples/searchActionTest/package.json @@ -21,6 +21,7 @@ "tsc": "tsc -p src" }, "dependencies": { + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "copyfiles": "^2.4.1", "dotenv": "^16.3.1", diff --git a/ts/examples/searchActionTest/src/main.ts b/ts/examples/searchActionTest/src/main.ts index 9a6cc54ed0..1455d3c9bb 100644 --- a/ts/examples/searchActionTest/src/main.ts +++ b/ts/examples/searchActionTest/src/main.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import { ChatModelWithStreaming, openai } from "aiclient"; import { CalendarSearchAction, CalendarDateTime } from "./calSearchSchema.js"; import { @@ -14,8 +14,7 @@ import { readSchemaFile } from "typechat-utils"; import * as fs from "fs"; -const envPath = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +loadConfigSync(); const schemaText = readSchemaFile("./calSearchSchema.ts"); const preamble = "You are a service that translates user calendar queries.\n"; diff --git a/ts/examples/vscodeSchemaGen/package.json b/ts/examples/vscodeSchemaGen/package.json index d004fcbc83..da2da8933b 100644 --- a/ts/examples/vscodeSchemaGen/package.json +++ b/ts/examples/vscodeSchemaGen/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@typeagent/action-schema": "workspace:*", + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "chalk": "^5.4.1", "copyfiles": "^2.4.1", diff --git a/ts/examples/vscodeSchemaGen/src/normalizeVscodeJson.ts b/ts/examples/vscodeSchemaGen/src/normalizeVscodeJson.ts index 0e53cb2733..797e165df2 100644 --- a/ts/examples/vscodeSchemaGen/src/normalizeVscodeJson.ts +++ b/ts/examples/vscodeSchemaGen/src/normalizeVscodeJson.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import { promises as fs } from "fs"; import * as path from "path"; import { fileURLToPath } from "url"; @@ -9,8 +9,7 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const envPath = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +loadConfigSync(); const misc_commandstxt_filePath = path.join( __dirname, diff --git a/ts/examples/vscodeSchemaGen/src/schemaGen.ts b/ts/examples/vscodeSchemaGen/src/schemaGen.ts index c2d7ffb983..e8254ad39e 100644 --- a/ts/examples/vscodeSchemaGen/src/schemaGen.ts +++ b/ts/examples/vscodeSchemaGen/src/schemaGen.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as path from "path"; -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import * as fs from "fs"; import { finished } from "stream/promises"; import { createPromptLogger } from "telemetry"; @@ -18,8 +18,7 @@ import { import { generateActionRequests } from "./actionGen.js"; import { dedupeList, generateEmbeddingWithRetry, TypeSchema } from "typeagent"; -const envPath = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +loadConfigSync(); async function getModelCompletionResponse( chatModel: ChatModelWithStreaming, diff --git a/ts/examples/websiteAliases/package.json b/ts/examples/websiteAliases/package.json index 7cc3fe40a8..bc61e3c073 100644 --- a/ts/examples/websiteAliases/package.json +++ b/ts/examples/websiteAliases/package.json @@ -29,6 +29,7 @@ "@azure/ai-projects": "^1.0.0-beta.8", "@azure/identity": "^4.10.0", "@typeagent/common-utils": "workspace:*", + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "azure-ai-foundry": "workspace:*", "chalk": "^5.4.1", diff --git a/ts/examples/websiteAliases/src/main.ts b/ts/examples/websiteAliases/src/main.ts index 4966267df7..f65cb4a784 100644 --- a/ts/examples/websiteAliases/src/main.ts +++ b/ts/examples/websiteAliases/src/main.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import { bingWithGrounding } from "azure-ai-foundry"; import { AIProjectClient } from "@azure/ai-projects"; import { DefaultAzureCredential } from "@azure/identity"; @@ -9,9 +9,7 @@ import { pageContentKeywordExtractor } from "./pageContentKeywords.js"; //import { topNDomainsExtractor } from "./topNsites.js"; import { searchResultsPhraseGenerator } from "./searchBackedPhraseGeneration.js"; -// Load environment variables from .env file -const envPath = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +loadConfigSync(); const groundingConfig: bingWithGrounding.ApiSettings = bingWithGrounding.apiSettingsFromEnv(); diff --git a/ts/examples/websiteAliases/src/search_BatchWorker.ts b/ts/examples/websiteAliases/src/search_BatchWorker.ts index 57fbc6ca5a..61529ca333 100644 --- a/ts/examples/websiteAliases/src/search_BatchWorker.ts +++ b/ts/examples/websiteAliases/src/search_BatchWorker.ts @@ -3,7 +3,7 @@ import { parentPort, workerData } from "worker_threads"; import chalk from "chalk"; -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import { bingWithGrounding, openPhraseGeneratorAgent } from "azure-ai-foundry"; import { AIProjectClient } from "@azure/ai-projects"; import { DefaultAzureCredential } from "@azure/identity"; @@ -67,9 +67,7 @@ async function processDomain( // This script expects workerData to contain { domains, modulePath } (async () => { - // Load environment variables from .env file - const envPath = new URL("../../../.env", import.meta.url); - dotenv.config({ path: envPath }); + loadConfigSync(); const groundingConfig: bingWithGrounding.ApiSettings = bingWithGrounding.apiSettingsFromEnv(); diff --git a/ts/package.json b/ts/package.json index 8c1251c60a..00df1f3996 100644 --- a/ts/package.json +++ b/ts/package.json @@ -61,9 +61,10 @@ "@types/node": "^22.0.0", "markdown-link-check": "^3.14.2", "prettier": "^3.5.3", - "shx": "^0.4.0" + "shx": "^0.4.0", + "tsx": "^4.21.0" }, - "packageManager": "pnpm@10.33.3+sha512.a19744364a7e248b92657a4ca5973f9354d21caf982579674b1c539f32c7420c47138ad8b1254df07aba9bc782d9b3029e3db34d5dbff974326eb74dac8ff489", + "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800", "engines": { "node": ">=22", "pnpm": ">=10" diff --git a/ts/packages/actionGrammar/package.json b/ts/packages/actionGrammar/package.json index 10aade0e51..220c124a8a 100644 --- a/ts/packages/actionGrammar/package.json +++ b/ts/packages/actionGrammar/package.json @@ -48,6 +48,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.101", "@typeagent/action-schema": "workspace:*", + "@typeagent/config": "workspace:*", "debug": "^4.4.0", "dotenv": "^16.4.5", "regexp.escape": "^2.0.1" diff --git a/ts/packages/actionGrammar/src/generation/generate-grammar-cli.ts b/ts/packages/actionGrammar/src/generation/generate-grammar-cli.ts index 2d98481bc1..c56b4076de 100644 --- a/ts/packages/actionGrammar/src/generation/generate-grammar-cli.ts +++ b/ts/packages/actionGrammar/src/generation/generate-grammar-cli.ts @@ -2,16 +2,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { config } from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import * as path from "path"; -import { fileURLToPath } from "url"; import * as fs from "fs"; -// Load .env file -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, "../../../.."); -config({ path: path.join(repoRoot, ".env") }); +loadConfigSync(); import { SchemaToGrammarGenerator } from "./schemaToGrammarGenerator.js"; import { loadSchemaInfo } from "./schemaReader.js"; diff --git a/ts/packages/actionGrammar/src/generation/generate-scenario-grammar-cli.ts b/ts/packages/actionGrammar/src/generation/generate-scenario-grammar-cli.ts index 391dc6307c..cbd71458ae 100644 --- a/ts/packages/actionGrammar/src/generation/generate-scenario-grammar-cli.ts +++ b/ts/packages/actionGrammar/src/generation/generate-scenario-grammar-cli.ts @@ -2,16 +2,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { config } from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import * as path from "path"; -import { fileURLToPath } from "url"; import * as fs from "fs"; -// Load .env file -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, "../../../.."); -config({ path: path.join(repoRoot, ".env") }); +loadConfigSync(); import { ScenarioBasedGrammarGenerator } from "./scenarioBasedGenerator.js"; import { loadSchemaInfo } from "./schemaReader.js"; diff --git a/ts/packages/actionGrammar/src/generation/test-grammar-cli.ts b/ts/packages/actionGrammar/src/generation/test-grammar-cli.ts index 28709f6433..ab499b8914 100644 --- a/ts/packages/actionGrammar/src/generation/test-grammar-cli.ts +++ b/ts/packages/actionGrammar/src/generation/test-grammar-cli.ts @@ -6,15 +6,9 @@ * Command line tool for testing grammar generation on individual request/action pairs */ -import { config } from "dotenv"; -import * as path from "path"; -import { fileURLToPath } from "url"; - -// Load .env file -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, "../../../.."); -config({ path: path.join(repoRoot, ".env") }); +import { loadConfigSync } from "@typeagent/config"; + +loadConfigSync(); import { ClaudeGrammarGenerator } from "./grammarGenerator.js"; import { loadSchemaInfo } from "./schemaReader.js"; diff --git a/ts/packages/agentSdkWrapper/README.md b/ts/packages/agentSdkWrapper/README.md index 481422bca5..b694212672 100644 --- a/ts/packages/agentSdkWrapper/README.md +++ b/ts/packages/agentSdkWrapper/README.md @@ -116,7 +116,7 @@ export AZURE_SPEECH_KEY=your-speech-key export AZURE_SPEECH_REGION=your-region # e.g., westus2, eastus ``` -Or create a `.env` file in the TypeAgent repository root (`ts` directory): +Or add these to `config.local.yaml` (under `speech`) in the TypeAgent repository root (`ts` directory). See `config.sample.yaml` for the full YAML structure. Legacy `.env` files are also supported: ``` AZURE_SPEECH_KEY=your-speech-key @@ -143,7 +143,7 @@ export AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com export AZURE_OPENAI_DEPLOYMENT_NAME=whisper # Optional, defaults to "whisper" ``` -Or create a `.env` file in the TypeAgent repository root (`ts` directory): +Or add these to `config.local.yaml` (under `azureOpenAI`): ``` AZURE_OPENAI_API_KEY=your-key @@ -159,7 +159,7 @@ Set your OpenAI API key as an environment variable: export OPENAI_API_KEY=sk-... ``` -Or create a `.env` file in the TypeAgent repository root (`ts` directory): +Or add it to `config.local.yaml` (under `openAI`): ``` OPENAI_API_KEY=sk-... diff --git a/ts/packages/agentSdkWrapper/package.json b/ts/packages/agentSdkWrapper/package.json index 20e2337b01..db9ae591d9 100644 --- a/ts/packages/agentSdkWrapper/package.json +++ b/ts/packages/agentSdkWrapper/package.json @@ -34,6 +34,7 @@ "@anthropic-ai/sdk": "^0.91.1", "@modelcontextprotocol/sdk": "^1.26.0", "@typeagent/action-schema": "workspace:*", + "@typeagent/config": "workspace:*", "action-grammar": "workspace:*", "agent-cache": "workspace:*", "aiclient": "workspace:*", diff --git a/ts/packages/agentSdkWrapper/src/cli.ts b/ts/packages/agentSdkWrapper/src/cli.ts index a3d10f76c5..2561dfac2e 100644 --- a/ts/packages/agentSdkWrapper/src/cli.ts +++ b/ts/packages/agentSdkWrapper/src/cli.ts @@ -2,16 +2,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { config } from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import * as path from "path"; import { fileURLToPath } from "url"; -// Load .env file from the TypeAgent repository root (ts directory) -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -// From dist/ go up to: agentSdkWrapper/ -> packages/ -> ts/ -const repoRoot = path.resolve(__dirname, "../../.."); -config({ path: path.join(repoRoot, ".env") }); +loadConfigSync(); import { query, diff --git a/ts/packages/agentSdkWrapper/src/generate-grammar-cli.ts b/ts/packages/agentSdkWrapper/src/generate-grammar-cli.ts index bae5bbc5f1..c56b4076de 100644 --- a/ts/packages/agentSdkWrapper/src/generate-grammar-cli.ts +++ b/ts/packages/agentSdkWrapper/src/generate-grammar-cli.ts @@ -2,16 +2,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { config } from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import * as path from "path"; -import { fileURLToPath } from "url"; import * as fs from "fs"; -// Load .env file -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, "../../.."); -config({ path: path.join(repoRoot, ".env") }); +loadConfigSync(); import { SchemaToGrammarGenerator } from "./schemaToGrammarGenerator.js"; import { loadSchemaInfo } from "./schemaReader.js"; diff --git a/ts/packages/agentServer/server/package.json b/ts/packages/agentServer/server/package.json index dbaf11526b..a3b666086f 100644 --- a/ts/packages/agentServer/server/package.json +++ b/ts/packages/agentServer/server/package.json @@ -38,6 +38,7 @@ "@typeagent/agent-server-client": "workspace:*", "@typeagent/agent-server-protocol": "workspace:*", "@typeagent/common-utils": "workspace:*", + "@typeagent/config": "workspace:*", "@typeagent/dispatcher-rpc": "workspace:*", "@typeagent/dispatcher-types": "workspace:*", "agent-dispatcher": "workspace:*", diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index 7587fdf181..ccb94bba01 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -34,7 +34,7 @@ import { import type { ChannelProvider } from "@typeagent/agent-rpc/channel"; import type { Dispatcher } from "agent-dispatcher"; import { PortRegistrar, SYSTEM_SESSION_CONTEXT_ID } from "agent-dispatcher"; -import dotenv from "dotenv"; +import { loadConfig } from "@typeagent/config"; import { writeServerPid, removeServerPid, @@ -43,8 +43,9 @@ import registerDebug from "debug"; import os from "node:os"; import { DefaultAzureCredential } from "@azure/identity"; -const envPath = new URL("../../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +// Load config from YAML layers + Key Vault (replacing legacy dotenv). +// vault.shared is auto-discovered from config.local.yaml / config.defaults.yaml. +await loadConfig({ keyVault: {}, strict: false }); const debugStartup = registerDebug("agent-server:startup"); diff --git a/ts/packages/agents/browser/package.json b/ts/packages/agents/browser/package.json index 57114960b3..9de7ca7b62 100644 --- a/ts/packages/agents/browser/package.json +++ b/ts/packages/agents/browser/package.json @@ -62,6 +62,7 @@ "@typeagent/agent-sdk": "workspace:*", "@typeagent/agent-server-protocol": "workspace:*", "@typeagent/common-utils": "workspace:*", + "@typeagent/config": "workspace:*", "@typeagent/dispatcher-rpc": "workspace:*", "aiclient": "workspace:*", "azure-ai-foundry": "workspace:*", diff --git a/ts/packages/agents/browser/test/run-content-discovery.mts b/ts/packages/agents/browser/test/run-content-discovery.mts index c4769b8ee2..d68063a1f4 100644 --- a/ts/packages/agents/browser/test/run-content-discovery.mts +++ b/ts/packages/agents/browser/test/run-content-discovery.mts @@ -7,22 +7,16 @@ * * Usage: * npx tsx test/run-content-discovery.mts [--verbose] - * - * Requires .env with Azure OpenAI API keys in the ts/ root. */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Load .env -const envPath = path.resolve(__dirname, "..", "..", "..", "..", ".env"); -if (fs.existsSync(envPath)) { - dotenv.config({ path: envPath }); -} +loadConfigSync(); import { openai as ai } from "aiclient"; import { createJsonTranslator } from "typechat"; diff --git a/ts/packages/agents/calendar/README.md b/ts/packages/agents/calendar/README.md index 5a94910e63..5de09852d8 100644 --- a/ts/packages/agents/calendar/README.md +++ b/ts/packages/agents/calendar/README.md @@ -14,7 +14,7 @@ These are declared in the `package.json` as export paths: ### Prerequisites -The code uses Microsoft Graph to access your Microsoft account, Azure Active Directory, and Outlook Microsoft Graph API. Microsoft Graph [quickstart](https://developer.microsoft.com/en-us/graph/quick-start?state=option-typescript) example makes it easy to create you own graph client. Once you have created your graph client application and demo tenant you can update the following variables in the `.env` file: +The code uses Microsoft Graph to access your Microsoft account, Azure Active Directory, and Outlook Microsoft Graph API. Microsoft Graph [quickstart](https://developer.microsoft.com/en-us/graph/quick-start?state=option-typescript) example makes it easy to create you own graph client. Once you have created your graph client application and demo tenant you can update the following variables in `config.local.yaml` (under `msGraph`) or the legacy `.env` file: ``` MSGRAPH_APP_CLIENTID diff --git a/ts/packages/agents/chat/README.md b/ts/packages/agents/chat/README.md index b756885ccd..fbdccec5bc 100644 --- a/ts/packages/agents/chat/README.md +++ b/ts/packages/agents/chat/README.md @@ -8,7 +8,7 @@ The sample demonstrates: - How the Chat Response schema allows the LLM to include one more **_lookups_** for additional information. - How the [Chat Response Handler](./src/chatResponseHandler.ts) may use a web search engine to perform the lookups and produce a typed response. -The sample includes an example implementation of lookups with Bing. To experiment with lookups, please add your Bing API key to the root **.env** file with the following key: +The sample includes an example implementation of lookups with Bing. To experiment with lookups, please add your Bing API key to the root **config.local.yaml** (under `bing.apiKey`) or the legacy **.env** file with the following key: **BING_API_KEY** If a key is not available, the agent will return a "No Information available" response. diff --git a/ts/packages/agents/email/README.md b/ts/packages/agents/email/README.md index 2a63d74de4..c4a68995c6 100644 --- a/ts/packages/agents/email/README.md +++ b/ts/packages/agents/email/README.md @@ -14,7 +14,7 @@ These are declared in the `package.json` as export paths: ### Prerequisites -The code uses Microsoft Graph to access your Microsoft account, Azure Active Directory, and Outlook Microsoft Graph API. Microsoft Graph [quickstart](https://developer.microsoft.com/en-us/graph/quick-start?state=option-typescript) example makes it easy to create you own graph client. Once you have created your graph client application and demo tenant you can update the following variables in the `.env` file: +The code uses Microsoft Graph to access your Microsoft account, Azure Active Directory, and Outlook Microsoft Graph API. Microsoft Graph [quickstart](https://developer.microsoft.com/en-us/graph/quick-start?state=option-typescript) example makes it easy to create you own graph client. Once you have created your graph client application and demo tenant you can update the following variables in `config.local.yaml` (under `msGraph`) or the legacy `.env` file: ``` MSGRAPH_APP_CLIENTID diff --git a/ts/packages/agents/greeting/README.md b/ts/packages/agents/greeting/README.md index c57611e082..e20a724f52 100644 --- a/ts/packages/agents/greeting/README.md +++ b/ts/packages/agents/greeting/README.md @@ -8,7 +8,7 @@ The sample demonstrates: - How the Chat Response schema allows the LLM to generate several responses and select one at random from the generated options. - How the [Greeting Command Handler](./src/greetingCommandHandler.ts) may use a web search engine to augment the generated responses with personalized information. -The sample includes an example implementation of lookups with Bing. To experiment with lookups, please add your Bing API key to the root **.env** file with the following key: +The sample includes an example implementation of lookups with Bing. To experiment with lookups, please add your Bing API key to the root **config.local.yaml** (under `bing.apiKey`) or the legacy **.env** file with the following key: **BING_API_KEY** If a key is not available, the agent will return a "No Information available" response. diff --git a/ts/packages/agents/image/README.md b/ts/packages/agents/image/README.md index 30f1ca4db6..1a84a151e4 100644 --- a/ts/packages/agents/image/README.md +++ b/ts/packages/agents/image/README.md @@ -5,12 +5,12 @@ Image dispatcher agent. This **sample agent** shows how to make calls to varying <Deprecated> [Bing [Image] Search is being Deprecated August 2025](https://learn.microsoft.com/en-us/microsoftsearch/retirement-microsoft-search-bing). -To experiment with lookups, please add your Bing API key to the root **.env** file with the following key: +To experiment with lookups, please add your Bing API key to the root **config.local.yaml** file (under `bing.apiKey`) or the legacy **.env** file with the following key: **BING_API_KEY** </Deprecated> -To experiment with image generation models, please add your API key or configure your gpt-image-1 endpoint in the root **.env** file with the following variable names: AZURE_OPENAI_API_KEY_IMAGE, AZURE_OPENAI_ENDPOINT_IMAGE. For identity based authentication to your endpoint specify the key as identity. +To experiment with image generation models, please add your API key or configure your gpt-image-1 endpoint in the root **config.local.yaml** (under `azureOpenAI.deployments` / `azureOpenAI.endpoints`) or the legacy **.env** file with the following variable names: AZURE_OPENAI_API_KEY_IMAGE, AZURE_OPENAI_ENDPOINT_IMAGE. For identity based authentication to your endpoint specify the key as identity. ## Trademarks diff --git a/ts/packages/agents/image/src/imageActionHandler.ts b/ts/packages/agents/image/src/imageActionHandler.ts index e0ef3d8426..d4297d7ecb 100644 --- a/ts/packages/agents/image/src/imageActionHandler.ts +++ b/ts/packages/agents/image/src/imageActionHandler.ts @@ -8,14 +8,18 @@ import { ActionResult, ActionResultSuccess, } from "@typeagent/agent-sdk"; -import { downloadImage } from "typechat-utils"; +import { downloadImage, getMimeType } from "typechat-utils"; import { createActionResult, createActionResultFromHtmlDisplayWithScript, } from "@typeagent/agent-sdk/helpers/action"; import { GeneratedImage, openai } from "aiclient"; import { randomBytes, randomUUID } from "crypto"; -import { CreateImageAction, ImageAction } from "./imageActionSchema.js"; +import { + CreateImageAction, + EditImageAction, + ImageAction, +} from "./imageActionSchema.js"; export function instantiate(): AppAgent { return { @@ -131,12 +135,149 @@ async function handlePhotoAction( } } break; + case "editImageAction": { + const editAction = action as EditImageAction; + result = await handleEditImage(editAction, photoContext); + break; + } default: throw new Error(`Unknown action: ${(action as any).actionName}`); } return result; } +async function handleEditImage( + editAction: EditImageAction, + photoContext: ActionContext, +): Promise { + const { editPrompt, sourceImage } = editAction.parameters; + if (!sourceImage) { + return createActionResult( + "No source image was provided. Please attach the image you'd like to edit.", + ); + } + if (!editPrompt) { + return createActionResult( + "No edit instruction was provided. Tell me how to transform the image (e.g. 'cartoonize this').", + ); + } + + photoContext.actionIO.setDisplay({ + type: "html", + content: ` +
+
+
Editing
+
`, + }); + + // Resolve the source image bytes from session storage. Attachments + // the user uploaded this turn live under `user_files/`; outputs from + // prior createImageAction/editImageAction calls live under + // `generated_images/`. Try both, plus the raw path as supplied. + const fileName = sourceImage.substring( + Math.max(sourceImage.lastIndexOf("\\"), sourceImage.lastIndexOf("/")) + + 1, + ); + const candidates: string[] = []; + // If the LLM gave us an explicit relative path, honor it first. + if (sourceImage.includes("/") || sourceImage.includes("\\")) { + candidates.push(`\\..\\${sourceImage.replace(/\//g, "\\")}`); + } + candidates.push(`\\..\\user_files\\${fileName}`); + candidates.push(`\\..\\generated_images\\${fileName}`); + + let b64: string | undefined; + let resolvedPath: string | undefined; + for (const candidate of candidates) { + try { + const data = await photoContext.sessionContext.sessionStorage?.read( + candidate, + "base64", + ); + if (data) { + b64 = data; + resolvedPath = candidate; + break; + } + } catch { + // try next candidate + } + } + if (!b64) { + photoContext.actionIO.setDisplay({ type: "html", content: "" }); + return createActionResult( + `Could not find source image '${sourceImage}' in session storage. Tried: ${candidates.join(", ")}`, + ); + } + const buffer = Buffer.from(b64, "base64"); + // Derive extension from whatever path actually resolved. + const resolvedName = resolvedPath ?? fileName; + const dotIdx = resolvedName.lastIndexOf("."); + const ext = dotIdx >= 0 ? resolvedName.substring(dotIdx) : ""; + const mime = ext ? getMimeType(ext) : "image/png"; + + const imageModel = openai.createImageModel(); + if (!imageModel.editImage) { + photoContext.actionIO.setDisplay({ type: "html", content: "" }); + return createActionResult( + "Image editing is not supported by the configured image model.", + ); + } + + const r = await imageModel.editImage( + buffer, + mime, + fileName, + editPrompt, + 1, + 1024, + 1024, + ); + + photoContext.actionIO.setDisplay({ type: "html", content: "" }); + + if (!r.success) { + return createActionResult(`Failed to edit the image. ${r.message}`); + } + + const urls: string[] = []; + const captions: string[] = []; + r.data.images.map((i) => { + urls.push(i.image_url); + captions.push(i.revised_prompt); + }); + + const result = createCarouselForImages(urls, captions); + + // Persist the edited image in the session store, mirroring createImageAction. + const id = randomUUID(); + const savedFileName = `../generated_images/${id.toString()}.png`; + let saved = false; + if (urls[0].startsWith("data:")) { + const base64Data = urls[0].substring(urls[0].indexOf(",") + 1); + const editedBuffer = Buffer.from(base64Data, "base64"); + photoContext.sessionContext.sessionStorage?.write( + savedFileName, + editedBuffer, + ); + saved = true; + } else { + saved = await downloadImage( + urls[0], + savedFileName, + photoContext.sessionContext.sessionStorage!, + ); + } + if (saved) { + result.entities.push({ + name: savedFileName.substring(3), + type: ["file", "image", "ai_generated"], + }); + } + return result; +} + function createCarouselForImages( images: string[], captions: string[], diff --git a/ts/packages/agents/image/src/imageActionSchema.ts b/ts/packages/agents/image/src/imageActionSchema.ts index 2ce16b7ead..e4c818bc1b 100644 --- a/ts/packages/agents/image/src/imageActionSchema.ts +++ b/ts/packages/agents/image/src/imageActionSchema.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export type ImageAction = CreateImageAction; +export type ImageAction = CreateImageAction | EditImageAction; // creates an image based on the supplied description export type CreateImageAction = { @@ -15,3 +15,26 @@ export type CreateImageAction = { numImages: number; }; }; + +// Edits / transforms an image the user has already supplied (typically +// the most recent attachment). Use this for requests like "cartoonize +// that image", "make a watercolor version of this photo", "stylize this +// as a pencil sketch", etc. +export type EditImageAction = { + actionName: "editImageAction"; + parameters: { + // the original request of the user + originalRequest: string; + // The natural-language description of the edit to perform + // (e.g. "cartoonize", "make this a watercolor painting"). + editPrompt: string; + // The file name of the source image to edit. This MUST be the + // exact name of an image that is either (a) attached to the + // current request, or (b) listed as an image entity from a + // recent turn (e.g. `generated_images/.png` from a prior + // editImageAction / createImageAction result). NEVER invent a + // filename — if no such image is available, do not emit this + // action; ask the user to attach an image instead. + sourceImage: string; + }; +}; diff --git a/ts/packages/agents/onboarding/AGENTS.md b/ts/packages/agents/onboarding/AGENTS.md index 1b8da5da67..7590a24c37 100644 --- a/ts/packages/agents/onboarding/AGENTS.md +++ b/ts/packages/agents/onboarding/AGENTS.md @@ -44,7 +44,7 @@ All artifacts are persisted at `~/.typeagent/onboarding//`. Th ## LLM usage -Each phase that requires LLM calls uses `aiclient`'s `createChatModelDefault(tag)`. Tags are namespaced as `onboarding:` (e.g. `onboarding:schemagen`). This follows the standard TypeAgent pattern — credentials come from `ts/.env`. +Each phase that requires LLM calls uses `aiclient`'s `createChatModelDefault(tag)`. Tags are namespaced as `onboarding:` (e.g. `onboarding:schemagen`). This follows the standard TypeAgent pattern — credentials come from `ts/config.local.yaml` (or the legacy `ts/.env`). ## Phase approval model diff --git a/ts/packages/agents/onboarding/src/uiCapture/README.md b/ts/packages/agents/onboarding/src/uiCapture/README.md index 845e82d349..af211ffb0f 100644 --- a/ts/packages/agents/onboarding/src/uiCapture/README.md +++ b/ts/packages/agents/onboarding/src/uiCapture/README.md @@ -285,7 +285,7 @@ The shipping smoke tests under `test/` exercise each phase: Run any of them with `node packages/agents/onboarding/dist/uiCapture/test/.js` after `pnpm --filter onboarding-agent run build`. -For a real crawl, env vars `AZURE_OPENAI_API_KEY_GPT_5` + `AZURE_OPENAI_ENDPOINT_GPT_5` must be set in `ts/.env`. Note that aiclient's env reading short-circuits on its empty-string default and doesn't fall back from `_GPT_5`-suffixed vars to base vars, so for non-default settings (timeouts etc.) the suffixed variant must also be set explicitly. The smoke tests handle this in their preamble. +For a real crawl, env vars `AZURE_OPENAI_API_KEY_GPT_5` + `AZURE_OPENAI_ENDPOINT_GPT_5` must be set in `ts/config.local.yaml` (under `azureOpenAI.deployments` / `azureOpenAI.endpoints`) or the legacy `ts/.env`. Note that aiclient's env reading short-circuits on its empty-string default and doesn't fall back from `_GPT_5`-suffixed vars to base vars, so for non-default settings (timeouts etc.) the suffixed variant must also be set explicitly. The smoke tests handle this in their preamble. ## Quality observations from the Clock crawl diff --git a/ts/packages/agents/player/README.md b/ts/packages/agents/player/README.md index 6ca53c8721..9dd42ec14b 100644 --- a/ts/packages/agents/player/README.md +++ b/ts/packages/agents/player/README.md @@ -9,7 +9,7 @@ To turn on Spotify integration you will need additional setup with Spotify to us 3. Click the button in the upper right labeled "Create App". 4. Fill in the form, making sure the Redirect URI is http://127.0.0.1:PORT/callback, where PORT is a **_previously unused_** four-digit port number you choose for the authorization redirect. 5. Click the settings button and copy down the Client ID and Client Secret (the client secret requires you to click 'View client secret'). -6. In your `.env` file, set `SPOTIFY_APP_CLI` to your Client ID and `SPOTIFY_APP_CLISEC` to your Client Secret. Also set `SPOTIFY_APP_PORT` to the PORT on your local machine that you chose in step 4. +6. In your `config.local.yaml`, set the Spotify credentials under the `spotify` section (see `config.sample.yaml`). Or in the legacy `.env` file, set `SPOTIFY_APP_CLI` to your Client ID and `SPOTIFY_APP_CLISEC` to your Client Secret. Also set `SPOTIFY_APP_PORT` to the PORT on your local machine that you chose in step 4. ## Music Player Spotify Integration diff --git a/ts/packages/agents/player/package.json b/ts/packages/agents/player/package.json index 0781480907..7d70163da2 100644 --- a/ts/packages/agents/player/package.json +++ b/ts/packages/agents/player/package.json @@ -29,6 +29,7 @@ "dependencies": { "@typeagent/agent-sdk": "workspace:*", "@typeagent/common-utils": "workspace:*", + "@typeagent/config": "workspace:*", "chalk": "^5.4.1", "debug": "^4.4.0", "dotenv": "^16.3.1", diff --git a/ts/packages/agents/player/src/client.ts b/ts/packages/agents/player/src/client.ts index 28ace09efd..0ffc60795c 100644 --- a/ts/packages/agents/player/src/client.ts +++ b/ts/packages/agents/player/src/client.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import path from "node:path"; import { DeletePlaylistAction, GetFavoritesAction, @@ -24,7 +23,7 @@ import { } from "./agent/playerSchema.js"; import { createTokenProvider } from "./defaultTokenProvider.js"; import chalk from "chalk"; -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; //import * as Filter from "./trackFilter.js"; import { TypeChatLanguageModel, createLanguageModel } from "typechat"; import { @@ -56,7 +55,6 @@ import { } from "./endpoints.js"; import { htmlStatus, printStatus } from "./playback.js"; import { SpotifyService } from "./service.js"; -import { fileURLToPath } from "node:url"; import { initializeUserData, mergeUserDataKind, @@ -131,8 +129,7 @@ function createNotFoundActionResult(kind: string, queryString?: string) { let languageModel: TypeChatLanguageModel | undefined; export function getTypeChatLanguageModel() { if (languageModel === undefined) { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - dotenv.config({ path: path.join(__dirname, "../../../.env") }); + loadConfigSync(); languageModel = createLanguageModel(process.env); } return languageModel; diff --git a/ts/packages/agents/player/src/defaultTokenProvider.ts b/ts/packages/agents/player/src/defaultTokenProvider.ts index abcc767e4c..2d452b1cb1 100644 --- a/ts/packages/agents/player/src/defaultTokenProvider.ts +++ b/ts/packages/agents/player/src/defaultTokenProvider.ts @@ -1,14 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { loadConfigSync } from "@typeagent/config"; import { TokenProvider } from "./tokenProvider.js"; import { Storage } from "@typeagent/agent-sdk"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.join(__dirname, "../../../../.env") }); +loadConfigSync(); const scopes = [ "playlist-read-collaborative", diff --git a/ts/packages/agents/video/README.md b/ts/packages/agents/video/README.md index bc42d2bd66..129a59ce74 100644 --- a/ts/packages/agents/video/README.md +++ b/ts/packages/agents/video/README.md @@ -2,7 +2,7 @@ Video generation agent. This **sample agent** shows how to make calls to video generation APIs (sora-2 on Azure OpenAI). -To experiment with video generation models, please add your API key or configure your Sora endpoint in the root **.env** file with the following variable names: AZURE_OPENAI_ENDPOINT_SORA_2, AZURE_OPENAI_API_KEY_SORA_2. For identity based authentication to your endpoint specify the key as identity. The agent uses the **sora-2** model for video generation. +To experiment with video generation models, please add your API key or configure your Sora endpoint in the root **config.local.yaml** (under `azureOpenAI.deployments` / `azureOpenAI.endpoints`) or the legacy **.env** file with the following variable names: AZURE_OPENAI_ENDPOINT_SORA_2, AZURE_OPENAI_API_KEY_SORA_2. For identity based authentication to your endpoint specify the key as identity. The agent uses the **sora-2** model for video generation. ## Trademarks diff --git a/ts/packages/aiclient/package.json b/ts/packages/aiclient/package.json index 37370ebaa5..c33d964867 100644 --- a/ts/packages/aiclient/package.json +++ b/ts/packages/aiclient/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@azure/identity": "^4.10.0", + "@typeagent/config": "workspace:*", "async": "^3.2.5", "debug": "^4.4.0", "typechat": "^0.1.1" diff --git a/ts/packages/aiclient/src/apiSettingsFromConfig.ts b/ts/packages/aiclient/src/apiSettingsFromConfig.ts new file mode 100644 index 0000000000..80c1091dac --- /dev/null +++ b/ts/packages/aiclient/src/apiSettingsFromConfig.ts @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Typed-config entry points for aiclient. + * + * These functions are the post-migration counterparts to + * `apiSettingsFromEnv` and friends. They take a typed `Config` + * (built once at startup from YAML / Vault / .env) and resolve + * deployment + region without any string-suffix arithmetic. + * + * Phase B of the typed-config migration: these live alongside the + * existing env-based functions; new callers should prefer these, + * existing callers can migrate at their own pace. Internally the + * env-based functions are now thin adapters that call into here + * via `buildConfig(env)`, so behavior is unchanged for both paths. + */ + +import { + buildConfig, + type AuthMode, + type Config, + type Deployment, + type DeploymentEndpoint, + type Region, +} from "@typeagent/config"; + +import { + AuthTokenProvider, + AzureTokenScopes, + createAzureTokenProvider, +} from "./auth.js"; +import type { AzureApiSettings } from "./azureSettings.js"; +import { ApiSettings, ModelType } from "./openai.js"; +import type { OpenAIApiSettings } from "./openaiSettings.js"; + +const azureTokenProvider = createAzureTokenProvider( + AzureTokenScopes.CogServices, +); + +function authToken(auth: AuthMode): { + apiKey: string; + tokenProvider?: AuthTokenProvider; +} { + if (auth.kind === "identity") { + return { apiKey: "identity", tokenProvider: azureTokenProvider }; + } + return { apiKey: auth.value }; +} + +/** + * Pick the highest-capacity endpoint from a deployment, falling back + * to the first endpoint when no entry has an explicit capacity. Used + * to auto-select a default embedding deployment when the config does + * not declare `azureOpenAI.defaultEmbedding`. + */ +function pickHighestCapacityEndpoint( + deployment: Deployment | undefined, +): DeploymentEndpoint | undefined { + if (!deployment || deployment.endpoints.length === 0) return undefined; + let best: DeploymentEndpoint | undefined; + let bestCapacity = -Infinity; + for (const ep of deployment.endpoints) { + const cap = ep.capacity ?? 0; + if (cap > bestCapacity) { + best = ep; + bestCapacity = cap; + } + } + return best ?? deployment.endpoints[0]; +} + +/** + * Look up a deployment's endpoint by name, optionally pinned to a + * specific region. When `region` is omitted, returns the highest- + * priority pool member (PTU before PAYG; insertion order otherwise). + */ +export function getDeploymentEndpoint( + config: Config, + deploymentName: string, + region?: Region, +): DeploymentEndpoint | undefined { + const dep = config.azureOpenAI.deployments.get(deploymentName); + if (!dep) return undefined; + if (region !== undefined) { + return dep.endpoints.find((ep) => ep.region === region); + } + return dep.endpoints[0]; +} + +/** + * Look up a deployment by name. Mirror of the legacy + * `endpointName` parameter — the name is the lowercase form of + * the env-var suffix (e.g. `"gpt_4_o"` for `GPT_4_O`). + */ +export function getDeployment( + config: Config, + deploymentName: string, +): Deployment | undefined { + return config.azureOpenAI.deployments.get(deploymentName); +} + +/** + * Build `AzureApiSettings` from a typed `Config` for the named + * deployment + model type. When `deploymentName` is omitted, falls + * back to the bare/default endpoint for the given model type. + * + * Mirrors `azureApiSettingsFromEnv` exactly; identity auth gets the + * shared Azure token provider attached. + */ +export function azureApiSettingsFromConfig( + config: Config, + modelType: ModelType, + deploymentName?: string, + region?: Region, +): AzureApiSettings { + const ao = config.azureOpenAI; + + let endpoint: DeploymentEndpoint | undefined; + if (deploymentName !== undefined) { + endpoint = getDeploymentEndpoint(config, deploymentName, region); + } + + if (endpoint === undefined) { + // Service-default fallback paths. + switch (modelType) { + case ModelType.Chat: + endpoint = ao.defaultChat; + break; + case ModelType.Embedding: + endpoint = + ao.defaultEmbedding ?? + pickHighestCapacityEndpoint( + ao.deployments.get("embedding"), + ); + break; + case ModelType.Image: + endpoint = ao.defaultImage; + break; + case ModelType.Video: + endpoint = ao.defaultVideo; + break; + } + } + + if (endpoint === undefined) { + const where = + deploymentName !== undefined + ? `deployment '${deploymentName}'${region ? ` in region '${region}'` : ""}` + : `default ${modelType}`; + throw new Error(`No Azure OpenAI endpoint configured for ${where}`); + } + + const auth = authToken(endpoint.auth); + const settings: AzureApiSettings = { + provider: "azure", + modelType, + apiKey: auth.apiKey, + endpoint: endpoint.endpoint, + supportsResponseFormat: ao.responseFormat, + maxConcurrency: ao.maxConcurrency, + timeout: ao.maxTimeoutMs, + maxRetryAttempts: ao.maxRetryAttempts, + ...(ao.maxPromptChars !== undefined + ? { maxPromptChars: ao.maxPromptChars } + : {}), + ...(ao.enableModelRequestLogging + ? { enableModelRequestLogging: true } + : {}), + ...(auth.tokenProvider ? { tokenProvider: auth.tokenProvider } : {}), + }; + return settings; +} + +/** + * Build `OpenAIApiSettings` from a typed `Config`. Errors if + * `config.openAI` is undefined. + */ +export function openAIApiSettingsFromConfig( + config: Config, + modelType: ModelType, +): OpenAIApiSettings { + const oai = config.openAI; + if (!oai) { + throw new Error("No OpenAI configuration available"); + } + const endpoint = + modelType === ModelType.Chat ? oai.endpoint : oai.endpointEmbedding; + if (!endpoint) { + throw new Error( + `No OpenAI endpoint configured for ${modelType === ModelType.Chat ? "chat" : "embedding"}`, + ); + } + const settings: OpenAIApiSettings = { + provider: "openai", + modelType, + apiKey: oai.apiKey, + endpoint, + ...(modelType === ModelType.Chat + ? oai.model !== undefined + ? { modelName: oai.model } + : {} + : oai.modelEmbedding !== undefined + ? { modelName: oai.modelEmbedding } + : {}), + ...(oai.organization !== undefined + ? { organization: oai.organization } + : {}), + supportsResponseFormat: oai.responseFormat, + maxConcurrency: oai.maxConcurrency, + timeout: oai.maxTimeoutMs, + maxRetryAttempts: oai.maxRetryAttempts, + }; + return settings; +} + +/** + * Generic entry point: returns either Azure or OpenAI settings. + * + * Prefers Azure when it's configured (we're Microsoft); falls back + * to OpenAI only when no Azure deployment / service default matches + * the requested model type. This is the opposite of the legacy + * `apiSettingsFromEnv`, which preferred OpenAI whenever + * `OPENAI_API_KEY` was set — that bias is not what we want for + * production traffic. + */ +export function apiSettingsFromConfig( + config: Config, + modelType: ModelType, + deploymentName?: string, + region?: Region, +): ApiSettings { + // Try Azure first. + try { + return azureApiSettingsFromConfig( + config, + modelType, + deploymentName, + region, + ); + } catch (azureError) { + // Fall back to OpenAI if it's configured; otherwise rethrow + // the more informative Azure error. + if (config.openAI?.apiKey) { + return openAIApiSettingsFromConfig(config, modelType); + } + throw azureError; + } +} + +/** + * Build a `Config` from a flat env record. Convenience for legacy + * callers that have an env-style `Record` + * in hand and want to route through the typed path. + * + * `undefined` values are dropped (typed Config has no notion of + * "set to undefined"). + */ +export function configFromEnvRecord( + env: Record, +): Config { + const flat: Record = {}; + for (const [k, v] of Object.entries(env)) { + if (typeof v === "string") flat[k] = v; + } + return buildConfig(flat); +} diff --git a/ts/packages/aiclient/src/azureSettings.ts b/ts/packages/aiclient/src/azureSettings.ts index 5d5c7e126c..b003eae98b 100644 --- a/ts/packages/aiclient/src/azureSettings.ts +++ b/ts/packages/aiclient/src/azureSettings.ts @@ -8,6 +8,11 @@ import { } from "./auth.js"; import { getEnvSetting, getIntFromEnv } from "./common.js"; import { CommonApiSettings, EnvVars, ModelType } from "./openai.js"; +import { azureApiSettingsFromConfig } from "./apiSettingsFromConfig.js"; +import { getRuntimeConfig } from "./runtimeConfig.js"; +import registerDebug from "debug"; + +const debugSettings = registerDebug("typeagent:aiclient:azureSettings"); export type AzureApiSettings = CommonApiSettings & { provider: "azure"; @@ -28,12 +33,43 @@ const azureTokenProvider = createAzureTokenProvider( * @param modelType * @param env * @returns + * + * @deprecated Use `azureApiSettingsFromConfig` from + * `./apiSettingsFromConfig.ts` instead. This function now consults + * the typed `@typeagent/config` runtime config before falling back + * to the legacy env scan, so existing callers keep working — but + * new code should take a `Config` and call the typed entry point + * directly. */ export function azureApiSettingsFromEnv( modelType: ModelType, env?: Record, endpointName?: string, ): AzureApiSettings { + // Prefer the typed-config path when the caller hasn't supplied a custom + // env map. This lets YAML-only configurations (where only suffixed + // deployments are defined) satisfy bare lookups via the synthesized + // service defaults, instead of throwing `Missing ApiSetting: + // AZURE_OPENAI_ENDPOINT`. When the caller passes an explicit `env`, + // honor it and use the legacy env-scan path unchanged. + if (env === undefined) { + try { + return azureApiSettingsFromConfig( + getRuntimeConfig(), + modelType, + endpointName?.toLowerCase(), + ); + } catch (e) { + debugSettings( + "typed-config lookup failed for %s/%s, falling back to env: %s", + modelType, + endpointName ?? "", + (e as Error).message, + ); + // fall through to legacy env scan + } + } + env ??= process.env; let settings: AzureApiSettings | undefined; @@ -164,13 +200,13 @@ function azureImageApiSettingsFromEnv( env, EnvVars.AZURE_OPENAI_API_KEY_GPT_IMAGE_1_5, endpointName, - env[EnvVars.AZURE_OPENAI_API_KEY_DALLE] ?? "identity", + env[EnvVars.AZURE_OPENAI_API_KEY_GPT_IMAGE] ?? "identity", ), endpoint: getEnvSetting( env, EnvVars.AZURE_OPENAI_ENDPOINT_GPT_IMAGE_1_5, endpointName, - env[EnvVars.AZURE_OPENAI_ENDPOINT_DALLE], + env[EnvVars.AZURE_OPENAI_ENDPOINT_GPT_IMAGE], ), }; } diff --git a/ts/packages/aiclient/src/endpointPoolFromConfig.ts b/ts/packages/aiclient/src/endpointPoolFromConfig.ts new file mode 100644 index 0000000000..f6521d7e56 --- /dev/null +++ b/ts/packages/aiclient/src/endpointPoolFromConfig.ts @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Typed-config endpoint pool discovery. + * + * Counterpart to `endpointPool.ts:discoverEndpointPool`, which scans + * env-var keys with `tailLooksLikeRegionSuffix` heuristics. This + * version walks a typed `Config.azureOpenAI.deployments` map directly + * — no string parsing, no prefix-collision guards, no `KNOWN_REGIONS` + * duplication. The typed `Deployment.pool` is already sorted by + * priority (PTU before PAYG), so we just iterate it. + * + * The `EndpointPool` shape returned here is identical to the legacy + * one, so all the existing pool-runtime functions (`pickEndpoint`, + * `markThrottled`, `markSuccess`, etc.) work unchanged. + */ + +import registerDebug from "debug"; +import { priorityQueue } from "async"; +import type { Config, DeploymentEndpoint, Region } from "@typeagent/config"; +import { regionToEnvSuffix } from "@typeagent/config"; +import { + azureApiSettingsFromConfig, + openAIApiSettingsFromConfig, +} from "./apiSettingsFromConfig.js"; +import type { + EndpointMode, + EndpointPool, + EndpointPoolMember, +} from "./endpointPool.js"; +import { ApiSettings, ModelProviders, ModelType } from "./openai.js"; +import type { FetchThrottler } from "./restClient.js"; + +const debugPool = registerDebug("typeagent:pool"); + +type PoolOverrideEntry = { + suffix?: string; + priority?: number; + mode?: EndpointMode; + tpm?: number; +}; + +function makeThrottler(maxConcurrency: number): FetchThrottler { + const q = priorityQueue<() => Promise>( + async (task) => task(), + maxConcurrency, + ); + return (fn: () => Promise) => q.push(fn); +} + +/** + * Reconstruct the legacy env-var suffix for a deployment endpoint. + * Used as a stable identity key so `AZURE_OPENAI_POOL_` JSON + * overrides (which are keyed by suffix) keep working. + * + * Format: `_[_PTU]`. The deployment + * portion already comes uppercased from the typed map keys (lowercase + * snake-case in YAML, but the shim and env-var convention use upper). + */ +function suffixFor( + deploymentName: string, + endpoint: DeploymentEndpoint, +): string { + const dep = deploymentName.toUpperCase(); + const reg = regionToEnvSuffix(endpoint.region); + return endpoint.mode === "PTU" ? `${dep}_${reg}_PTU` : `${dep}_${reg}`; +} + +function readPoolOverride( + config: Config, + deploymentName: string | undefined, +): Map | undefined { + if (!deploymentName) return undefined; + const overrideKey = `AZURE_OPENAI_POOL_${deploymentName.toUpperCase()}`; + const raw = config.extra.get(overrideKey); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + debugPool( + `ignoring ${overrideKey}: expected JSON array, got ${typeof parsed}`, + ); + return undefined; + } + const m = new Map(); + for (const e of parsed) { + if (e && typeof e === "object" && typeof e.suffix === "string") { + m.set(e.suffix, e as PoolOverrideEntry); + } + } + return m; + } catch (e: any) { + debugPool(`ignoring ${overrideKey}: invalid JSON (${e?.message})`); + return undefined; + } +} + +function attachThrottler(settings: ApiSettings): void { + if ( + settings.maxConcurrency !== undefined && + settings.throttler === undefined + ) { + settings.throttler = makeThrottler(settings.maxConcurrency); + } +} + +function memberFromEndpoint( + deploymentName: string, + region: Region, + endpoint: DeploymentEndpoint, + settings: ApiSettings, + overrides?: Map, +): EndpointPoolMember { + const suffix = suffixFor(deploymentName, endpoint); + const override = overrides?.get(suffix); + return { + suffix, + region, + priority: override?.priority ?? endpoint.priority, + mode: override?.mode ?? endpoint.mode, + ...(override?.tpm !== undefined + ? { declaredTpm: override.tpm } + : endpoint.tpm !== undefined + ? { declaredTpm: endpoint.tpm } + : {}), + settings, + cooldownUntil: 0, + consecutive429s: 0, + consecutiveSuccesses: 0, + }; +} + +/** + * Build an endpoint pool from a typed `Config`. + * + * - For Azure: enumerate the named deployment's region map (or the + * service-default endpoint when no name is given for embedding/ + * image/video), wrap each member in an ApiSettings via + * `azureApiSettingsFromConfig`, attach a per-member throttler. + * - For OpenAI: single-member pool from `config.openAI`. + * - Ollama: not supported (matches legacy). + * + * `AZURE_OPENAI_POOL_` JSON overrides in `config.extra` still + * tweak per-member priority/mode/tpm by suffix, exactly like the + * env-based path. + */ +export function discoverEndpointPoolFromConfig( + config: Config, + provider: ModelProviders, + modelType: ModelType, + deploymentName?: string, +): EndpointPool { + if (provider === "ollama") { + throw new Error( + "discoverEndpointPoolFromConfig is not applicable to ollama provider", + ); + } + + const modelKey = `${provider}:${deploymentName ?? ""}`; + + if (provider === "openai") { + const settings = openAIApiSettingsFromConfig(config, modelType); + attachThrottler(settings); + return { + modelKey, + members: [ + { + suffix: deploymentName ?? "", + priority: 1, + mode: "unknown", + settings, + cooldownUntil: 0, + consecutive429s: 0, + consecutiveSuccesses: 0, + }, + ], + }; + } + + // Azure path. + const overrides = readPoolOverride(config, deploymentName); + + // Named-deployment case: walk the typed pool array directly. + if (deploymentName !== undefined) { + const dep = config.azureOpenAI.deployments.get(deploymentName); + if (dep && dep.endpoints.length > 0) { + const members: EndpointPoolMember[] = []; + for (const endpoint of dep.endpoints) { + let settings: ApiSettings; + try { + settings = azureApiSettingsFromConfig( + config, + modelType, + deploymentName, + endpoint.region, + ); + } catch (e: any) { + debugPool( + `skipping pool member "${suffixFor(deploymentName, endpoint)}": ${e?.message}`, + ); + continue; + } + attachThrottler(settings); + members.push( + memberFromEndpoint( + deploymentName, + endpoint.region, + endpoint, + settings, + overrides, + ), + ); + } + if (members.length > 0) { + if (debugPool.enabled) { + debugPool( + `built pool ${modelKey}: ${members + .map( + (m) => + `{suffix=${m.suffix || ""}, priority=${m.priority}, mode=${m.mode}${m.region ? `, region=${m.region}` : ""}}`, + ) + .join(", ")}`, + ); + } + return { modelKey, members }; + } + } + // Fall through: deployment not in typed map. Try the bare/service + // default fallback below — `azureApiSettingsFromConfig` will throw + // a descriptive "No Azure OpenAI endpoint configured" if there is + // truly nothing to fall back to. + } + + // Bare / service-default case (no deploymentName, or named lookup + // missed and we want the same "missing setting" error legacy callers + // would have seen). + const settings = azureApiSettingsFromConfig( + config, + modelType, + deploymentName, + ); + attachThrottler(settings); + return { + modelKey, + members: [ + { + suffix: deploymentName ?? "", + priority: 1, + mode: "unknown", + settings, + cooldownUntil: 0, + consecutive429s: 0, + consecutiveSuccesses: 0, + }, + ], + }; +} diff --git a/ts/packages/aiclient/src/index.ts b/ts/packages/aiclient/src/index.ts index 80b918cd64..0a28e8bfb2 100644 --- a/ts/packages/aiclient/src/index.ts +++ b/ts/packages/aiclient/src/index.ts @@ -12,3 +12,17 @@ export { getChatModelNames, getChatModelMaxConcurrency, } from "./modelResource.js"; +export { + apiSettingsFromConfig, + azureApiSettingsFromConfig, + openAIApiSettingsFromConfig, + configFromEnvRecord, + getDeployment, + getDeploymentEndpoint, +} from "./apiSettingsFromConfig.js"; +export { discoverEndpointPoolFromConfig } from "./endpointPoolFromConfig.js"; +export { + getRuntimeConfig, + setRuntimeConfig, + initRuntimeConfigFromProcessEnv, +} from "./runtimeConfig.js"; diff --git a/ts/packages/aiclient/src/modelResource.ts b/ts/packages/aiclient/src/modelResource.ts index cba39ee9f5..92ef9818c2 100644 --- a/ts/packages/aiclient/src/modelResource.ts +++ b/ts/packages/aiclient/src/modelResource.ts @@ -3,6 +3,7 @@ import { openai as ai } from "aiclient"; import { getOllamaModelNames } from "./ollamaModels.js"; +import { getRuntimeConfig } from "./runtimeConfig.js"; export function getChatModelMaxConcurrency( userMaxConcurrency?: number, @@ -22,108 +23,27 @@ export function getChatModelMaxConcurrency( } // Tail tokens that represent region / PTU variants rather than distinct -// models. When enumerating model names for the UI, a key like -// AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS should surface as "GPT_4_O", not -// "GPT_4_O_EASTUS" — otherwise every regional variant pollutes the model -// picker with a bogus "model". -const REGION_TAIL_TOKENS = new Set([ - "EASTUS", - "EASTUS2", - "WESTUS", - "WESTUS2", - "WESTUS3", - "CENTRALUS", - "NORTHCENTRALUS", - "SOUTHCENTRALUS", - "WESTCENTRALUS", - "SWEDEN", - "SWEDENCENTRAL", - "FRANCECENTRAL", - "GERMANYWESTCENTRAL", - "NORWAYEAST", - "NORTHEUROPE", - "WESTEUROPE", - "UKSOUTH", - "UKWEST", - "SWITZERLANDNORTH", - "JAPANEAST", - "JAPANWEST", - "AUSTRALIAEAST", - "KOREACENTRAL", - "SOUTHEASTASIA", - "EASTASIA", - "CENTRALINDIA", - "SOUTHINDIA", - "BRAZILSOUTH", - "CANADACENTRAL", - "CANADAEAST", - "JAPAN", - "AUSTRALIA", - "BRAZIL", - "CANADA", - "KOREA", - "UK", - "PTU", -]); - -function stripRegionTail(suffix: string): string { - // Strip a trailing _PTU and a trailing _. We only strip when the - // trailing token matches a known region token — otherwise we'd collapse - // genuinely distinct model suffixes. - const parts = suffix.split("_"); - while (parts.length > 1) { - const last = parts[parts.length - 1]; - if (REGION_TAIL_TOKENS.has(last)) { - parts.pop(); - } else { - break; - } - } - // Also handle multi-token regions that collide when split by "_", e.g. - // SWEDEN_CENTRAL, NORTH_CENTRAL_US. Rejoin the remaining tail tokens and - // check if the concatenation is a known region. - while (parts.length > 1) { - const joined = parts.slice(-2).join(""); - if (REGION_TAIL_TOKENS.has(joined)) { - parts.splice(-2, 2); - continue; - } - const joined3 = - parts.length >= 3 ? parts.slice(-3).join("") : undefined; - if (joined3 && REGION_TAIL_TOKENS.has(joined3)) { - parts.splice(-3, 3); - continue; - } - break; - } - return parts.join("_"); -} +// models. (Previously used to strip suffixes from env-var-derived names. +// The typed Config now keys deployments by canonical name, so no stripping +// is needed.) export async function getChatModelNames() { - const envKeys = Object.keys(process.env); - const knownEnvKeys = Object.keys(ai.EnvVars); + // Azure deployment names come from the typed Config. The typed + // map keys deployments by name directly — no need to scan env-var + // prefixes and strip region tails. Names are uppercased to match + // the legacy env-suffix convention that consumers expect. + const config = getRuntimeConfig(); + const azureNames = [...config.azureOpenAI.deployments.keys()].map((n) => + n.toUpperCase(), + ); - const getPrefixedNames = (name: string) => { - const prefix = `${name}_`; - return envKeys - .filter( - (key) => - key.startsWith(prefix) && - knownEnvKeys.every( - (knownKey) => - knownKey === name || !key.startsWith(knownKey), - ), - ) - .map((key) => key.replace(prefix, "")) - .map(stripRegionTail) - .filter((name) => name.length > 0); - }; - const azureNames = [ - ...new Set(getPrefixedNames(ai.EnvVars.AZURE_OPENAI_API_KEY)), - ]; - const openaiNames = [ - ...new Set(getPrefixedNames(ai.EnvVars.OPENAI_API_KEY)), - ].map((key) => `openai:${key}`); + // OpenAI named variants come from the typed `OpenAIConfig`. The + // only named variant currently modeled is `openAI.local`, which + // surfaces as `openai:LOCAL`. + const openaiNames: string[] = []; + if (config.openAI?.local !== undefined) { + openaiNames.push("openai:LOCAL"); + } return [...azureNames, ...openaiNames, ...(await getOllamaModelNames())]; } diff --git a/ts/packages/aiclient/src/models.ts b/ts/packages/aiclient/src/models.ts index c463a721c6..446d35a655 100644 --- a/ts/packages/aiclient/src/models.ts +++ b/ts/packages/aiclient/src/models.ts @@ -11,6 +11,11 @@ import { CompletionUsageStats } from "./openai.js"; export type CompletionSettings = { n?: number; temperature?: number; + /** + * @deprecated Use `max_completion_tokens` instead. The runtime + * auto-promotes this to `max_completion_tokens` before sending the + * request, but new code should set `max_completion_tokens` directly. + */ max_tokens?: number; response_format?: { type: "json_object" }; // Use fixed seed parameter to improve determinism @@ -18,7 +23,6 @@ export type CompletionSettings = { seed?: number; top_p?: number; - // GPT-5 specific settings max_completion_tokens?: number; reasoning_effort?: "minimal" | "low" | "medium" | "high"; verbosity?: "low" | "medium" | "high"; @@ -128,6 +132,23 @@ export interface ImageModel { width: number, height: number, ): Promise>; + + /** + * Edit an existing image with a natural-language prompt. The + * source image is supplied as raw bytes (typically the contents + * of a PNG / JPG file the user attached). Implementations should + * route this to the provider's image-edit endpoint + * (e.g. Azure / OpenAI `/images/edits`). + */ + editImage?( + sourceImage: Buffer, + sourceMimeType: string, + sourceFileName: string, + prompt: string, + imageCount: number, + width: number, + height: number, + ): Promise>; } export type ImageGeneration = { diff --git a/ts/packages/aiclient/src/openai.ts b/ts/packages/aiclient/src/openai.ts index 3d377011f9..f2dbb51d1d 100644 --- a/ts/packages/aiclient/src/openai.ts +++ b/ts/packages/aiclient/src/openai.ts @@ -26,6 +26,8 @@ import { EndpointPool, makeSingleMemberPool, } from "./endpointPool.js"; +import { discoverEndpointPoolFromConfig } from "./endpointPoolFromConfig.js"; +import { getRuntimeConfig } from "./runtimeConfig.js"; import { PromptSection, Result, @@ -118,9 +120,9 @@ export enum EnvVars { AZURE_OPENAI_API_KEY_GPT_IMAGE_1_5 = "AZURE_OPENAI_API_KEY_GPT_IMAGE_1_5", AZURE_OPENAI_ENDPOINT_GPT_IMAGE_1_5 = "AZURE_OPENAI_ENDPOINT_GPT_IMAGE_1_5", - // Deprecated: use AZURE_OPENAI_API_KEY_GPT_IMAGE_1_5 / AZURE_OPENAI_ENDPOINT_GPT_IMAGE_1_5 - AZURE_OPENAI_API_KEY_DALLE = "AZURE_OPENAI_API_KEY_DALLE", - AZURE_OPENAI_ENDPOINT_DALLE = "AZURE_OPENAI_ENDPOINT_DALLE", + // Generic fallback for any current/future image model + AZURE_OPENAI_API_KEY_GPT_IMAGE = "AZURE_OPENAI_API_KEY_GPT_IMAGE", + AZURE_OPENAI_ENDPOINT_GPT_IMAGE = "AZURE_OPENAI_ENDPOINT_GPT_IMAGE", AZURE_OPENAI_API_KEY_SORA_2 = "AZURE_OPENAI_API_KEY_SORA_2", AZURE_OPENAI_ENDPOINT_SORA_2 = "AZURE_OPENAI_ENDPOINT_SORA_2", @@ -143,6 +145,16 @@ export const MAX_PROMPT_LENGTH_DEFAULT = 1000 * 60; * @param env Environment variables or arbitrary Record * @param endpointName optional suffix to add to env variable names. Lets you target different backends * @returns + * + * @deprecated Use the typed-config entry points instead + * (`azureApiSettingsFromConfig` / `openAIApiSettingsFromConfig` in + * `apiSettingsFromConfig.ts`). The env-based path bypasses the YAML + * config loaded via `@typeagent/config` and will fail when only + * suffixed deployments (e.g. `AZURE_OPENAI_ENDPOINT_GPT_4_O`) are + * configured without a bare `AZURE_OPENAI_ENDPOINT`. Existing + * callers continue to work because this function now consults the + * typed config before falling back to env scanning, but new code + * should call the typed entry points directly. */ export function apiSettingsFromEnv( modelType: ModelType = ModelType.Chat, @@ -270,11 +282,37 @@ function getModelPool( const key = `${modelType}:${provider}:${endpointName ?? ""}`; const existing = modelPools.get(key); if (existing) return existing; - const pool = discoverEndpointPool(provider, modelType, endpointName); + const pool = buildModelPool(provider, modelType, endpointName); modelPools.set(key, pool); return pool; } +// Try the typed-Config path first; if it fails (typically because the +// caller's deployment uses a non-canonical region alias that the typed +// REGIONS set doesn't carry), fall back to the legacy env-scanning +// discovery. Both paths read the same underlying values via process.env +// / the singleton built from it, so the pool contents converge. +function buildModelPool( + provider: ModelProviders, + modelType: ModelType, + endpointName?: string, +): EndpointPool { + if (provider !== "ollama") { + try { + const typedName = endpointName?.toLowerCase(); + return discoverEndpointPoolFromConfig( + getRuntimeConfig(), + provider, + modelType, + typedName, + ); + } catch { + // Fall through to legacy. + } + } + return discoverEndpointPool(provider, modelType, endpointName); +} + export function getChatModelPool(endpoint?: string): EndpointPool { const endpointName = parseEndPointName(endpoint); const key = `${ModelType.Chat}:${endpointName.provider}:${endpointName.name ?? ""}`; @@ -485,6 +523,18 @@ function createAzureOpenAIChatModel( completionSettings.n ??= 1; completionSettings.temperature ??= 0; + // Normalize max_tokens → max_completion_tokens. Newer models (GPT-5, + // o3, o4, GPT-4.1, etc.) reject the legacy `max_tokens` parameter. + // Promote it unconditionally — all supported models accept the new name. + if ( + completionSettings.max_tokens !== undefined && + completionSettings.max_completion_tokens === undefined + ) { + completionSettings.max_completion_tokens = + completionSettings.max_tokens; + } + delete completionSettings.max_tokens; + const disableResponseFormat = !settings.supportsResponseFormat && completionSettings.response_format !== undefined; @@ -979,6 +1029,7 @@ export function createImageModel(apiSettings?: ApiSettings): ImageModel { }; const model: ImageModel = { generateImage, + editImage, }; return model; @@ -1030,6 +1081,86 @@ export function createImageModel(apiSettings?: ApiSettings): ImageModel { return success(retValue); } + async function editImage( + sourceImage: Buffer, + sourceMimeType: string, + sourceFileName: string, + prompt: string, + imageCount: number, + width: number, + height: number, + ): Promise> { + if (imageCount !== 1) { + throw Error("n MUST equal 1"); + } + // Derive the edits URL from the configured generations URL. + // Azure deployments expose `/images/generations` and a parallel + // `/images/edits` on the same deployment path; preserve any + // `?api-version=...` querystring. + const member = pool.members[0]; + const generationsUrl = member.settings.endpoint; + const editsUrl = generationsUrl.replace( + "/images/generations", + "/images/edits", + ); + if (editsUrl === generationsUrl) { + return error( + `Configured image endpoint does not contain '/images/generations'; cannot derive edits URL: ${generationsUrl}`, + ); + } + + const headerResult = await createApiHeaders(member.settings); + if (!headerResult.success) { + return headerResult; + } + // Strip Content-Type if present; let fetch set the multipart boundary. + const headers: Record = { ...headerResult.data }; + delete headers["Content-Type"]; + delete headers["content-type"]; + + const form = new FormData(); + const blob = new Blob([sourceImage as unknown as ArrayBuffer], { + type: sourceMimeType, + }); + form.append("image", blob, sourceFileName); + form.append("prompt", prompt); + form.append("n", String(imageCount)); + form.append("size", `${width}x${height}`); + if (member.settings.provider !== "azure" && settings.modelName) { + form.append("model", settings.modelName); + } + + let response: Response; + try { + response = await fetch(editsUrl, { + method: "POST", + headers, + body: form, + }); + } catch (e) { + return error(`Image edit request failed: ${(e as Error).message}`); + } + if (!response.ok) { + const body = await response.text().catch(() => ""); + return error( + `Image edit request returned ${response.status} ${response.statusText}: ${body}`, + ); + } + const data = (await response.json()) as ImageCompletion; + const retValue: ImageGeneration = { images: [] }; + data.data.map((i) => { + verifyContentSafety(i); + const image_url = i.b64_json + ? `data:image/png;base64,${i.b64_json}` + : (i.url ?? ""); + retValue.images.push({ + revised_prompt: i.revised_prompt ?? prompt, + image_url, + }); + }); + return success(retValue); + } + function verifyContentSafety(data: ImageData): boolean { verifyFilterResults(data.content_filter_results as FilterResult); verifyFilterResults(data.prompt_filter_results as FilterResult); @@ -1077,7 +1208,10 @@ function getPromptLength(prompt: string | PromptSection[]) { * @param apiSettings: settings to use to create the client */ export function createVideoModel(apiSettings?: ApiSettings): VideoModel { - const settings = apiSettings ?? apiSettingsFromEnv(ModelType.Video); + const pool = apiSettings + ? makeSingleMemberPool(apiSettings, `custom:${apiSettings.provider}`) + : getModelPool(defaultProvider(), ModelType.Video); + const settings = pool.members[0].settings; const defaultParams = settings.provider === "azure" ? {} @@ -1097,10 +1231,6 @@ export function createVideoModel(apiSettings?: ApiSettings): VideoModel { height: number = 720, inpaintItems?: ImageInPaintItem[], ): Promise> { - const headerResult = await createApiHeaders(settings); - if (!headerResult.success) { - return headerResult; - } if (numVariants < 0 || numVariants > 2) { throw Error("n MUST equal 1"); // as of 10.09.2025 API will only accept n<2 } @@ -1139,22 +1269,26 @@ export function createVideoModel(apiSettings?: ApiSettings): VideoModel { else formData.append(k, v); } - // send it - try { - const result = await fetch(settings.endpoint, { - method: "POST", - headers: headerResult.data, - body: formData, - }); - - if (!result.ok) { - return error(`Error ${result.status}: ${await result.text()}`); - } + const response = await callApiWithPool( + pool, + async (member) => { + const headerResult = await createApiHeaders(member.settings); + if (!headerResult.success) return headerResult; + return success({ + headers: headerResult.data, + body: formData, + }); + }, + { retryPauseMs: settings.retryPauseMs }, + ); + if (!response.success) return response; + try { + const jobJson = (await response.data.json()) as VideoGenerationJob; return success({ endpoint: new URL(settings.endpoint), - headers: headerResult.data, - ...((await result.json()) as VideoGenerationJob), + headers: {}, + ...jobJson, }); } catch (err) { return error(`Error: ${err}`); diff --git a/ts/packages/aiclient/src/restClient.ts b/ts/packages/aiclient/src/restClient.ts index 484821f5cf..f2e93d07b2 100644 --- a/ts/packages/aiclient/src/restClient.ts +++ b/ts/packages/aiclient/src/restClient.ts @@ -570,16 +570,21 @@ async function fetchWithPool( const member = pool.members[0]; const req = await buildRequest(member); if (!req.success) return req; + const isMultipart = req.data.body instanceof FormData; const init: RequestInit = { method, - headers: { - "content-type": "application/json", - ...req.data.headers, - }, + headers: isMultipart + ? { ...req.data.headers } + : { + "content-type": "application/json", + ...req.data.headers, + }, signal: options?.signal ?? null, }; if (method === "POST" && req.data.body !== undefined) { - init.body = JSON.stringify(req.data.body); + init.body = isMultipart + ? (req.data.body as FormData) + : JSON.stringify(req.data.body); } const settings = member.settings; return fetchWithRetry( @@ -640,16 +645,21 @@ async function fetchWithPool( // per-endpoint recoverable — bubble it out rather than rotating. return req; } + const isMultipart = req.data.body instanceof FormData; const init: RequestInit = { method, - headers: { - "content-type": "application/json", - ...req.data.headers, - }, + headers: isMultipart + ? { ...req.data.headers } + : { + "content-type": "application/json", + ...req.data.headers, + }, signal: options?.signal ?? null, }; if (method === "POST" && req.data.body !== undefined) { - init.body = JSON.stringify(req.data.body); + init.body = isMultipart + ? (req.data.body as FormData) + : JSON.stringify(req.data.body); } attempts += 1; diff --git a/ts/packages/aiclient/src/runtimeConfig.ts b/ts/packages/aiclient/src/runtimeConfig.ts new file mode 100644 index 0000000000..449c5bc5a3 --- /dev/null +++ b/ts/packages/aiclient/src/runtimeConfig.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Process-wide singleton accessor for the typed runtime `Config`. + * + * The shell / cli / api hosts each populate `process.env` early (from + * YAML, .env, key vault, OS env, in their preferred precedence). Once + * that is done they call `setRuntimeConfig(buildConfig(process.env))` + * — or just `initRuntimeConfigFromProcessEnv()` for the common case — + * and from that point any aiclient code can obtain the typed view by + * calling `getRuntimeConfig()`. + * + * Until the explicit init call, `getRuntimeConfig()` lazily builds + * one from `process.env` on first access. That makes existing tests + * and ad-hoc scripts Just Work, while still letting hosts pin a + * curated config (e.g. one assembled from multiple files) up front. + */ + +import { buildConfig, type Config } from "@typeagent/config"; + +let cached: Config | undefined; + +/** + * Replace the cached typed Config. Hosts should call this after they + * finish populating `process.env` and before any aiclient operation. + */ +export function setRuntimeConfig(config: Config): void { + cached = config; +} + +/** + * Build a Config from the current `process.env` and install it as the + * cached singleton. Convenience for the common host-startup case. + */ +export function initRuntimeConfigFromProcessEnv(): Config { + const flat: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (typeof v === "string") flat[k] = v; + } + cached = buildConfig(flat); + return cached; +} + +/** + * Return the cached typed Config, building one lazily from + * `process.env` if no host has installed one yet. + */ +export function getRuntimeConfig(): Config { + if (cached === undefined) { + return initRuntimeConfigFromProcessEnv(); + } + return cached; +} + +/** + * Test-only: clear the cached singleton so the next access rebuilds. + * Not exported through the package barrel. + */ +export function _resetRuntimeConfigForTests(): void { + cached = undefined; +} diff --git a/ts/packages/aiclient/test/apiSettingsFromConfig.spec.ts b/ts/packages/aiclient/test/apiSettingsFromConfig.spec.ts new file mode 100644 index 0000000000..f7b77ab179 --- /dev/null +++ b/ts/packages/aiclient/test/apiSettingsFromConfig.spec.ts @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + apiSettingsFromConfig, + azureApiSettingsFromConfig, + configFromEnvRecord, + getDeployment, + getDeploymentEndpoint, + openAIApiSettingsFromConfig, +} from "../src/index.js"; +import { azureApiSettingsFromEnv } from "../src/azureSettings.js"; +import { ModelType } from "../src/openai.js"; + +describe("apiSettingsFromConfig: typed-config path equivalence", () => { + test("Azure chat single deployment matches env-based path", () => { + const env = { + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://4o-eastus", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "k1", + AZURE_OPENAI_MAX_CONCURRENCY: "8", + AZURE_OPENAI_MAX_TIMEOUT: "30000", + AZURE_OPENAI_RESPONSE_FORMAT: "1", + }; + const config = configFromEnvRecord(env); + const fromConfig = azureApiSettingsFromConfig( + config, + ModelType.Chat, + "gpt_4_o", + "eastus", + ); + expect(fromConfig.provider).toBe("azure"); + expect(fromConfig.endpoint).toBe("https://4o-eastus"); + expect(fromConfig.apiKey).toBe("k1"); + expect(fromConfig.maxConcurrency).toBe(8); + expect(fromConfig.timeout).toBe(30000); + expect(fromConfig.supportsResponseFormat).toBe(true); + }); + + test("Azure: identity auth attaches token provider", () => { + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://4o", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + }); + const s = azureApiSettingsFromConfig(config, ModelType.Chat, "gpt_4_o"); + expect(s.apiKey).toBe("identity"); + expect(s.tokenProvider).toBeDefined(); + }); + + test("Azure: explicit key auth has no token provider", () => { + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://4o", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "sk-real-key", + }); + const s = azureApiSettingsFromConfig(config, ModelType.Chat, "gpt_4_o"); + expect(s.apiKey).toBe("sk-real-key"); + expect(s.tokenProvider).toBeUndefined(); + }); + + test("Azure: highest-priority pool member chosen by default (PTU before PAYG)", () => { + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://4o-payg", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_SWEDENCENTRAL_PTU: + "https://4o-sw-ptu", + AZURE_OPENAI_API_KEY_GPT_4_O_SWEDENCENTRAL_PTU: "identity", + }); + const s = azureApiSettingsFromConfig(config, ModelType.Chat, "gpt_4_o"); + expect(s.endpoint).toBe("https://4o-sw-ptu"); + }); + + test("Azure: bare embedding endpoint as service default", () => { + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_EMBEDDING: "https://emb-default", + AZURE_OPENAI_API_KEY_EMBEDDING: "k", + }); + const s = azureApiSettingsFromConfig(config, ModelType.Embedding); + expect(s.endpoint).toBe("https://emb-default"); + expect(s.apiKey).toBe("k"); + }); + + test("Azure: missing deployment throws", () => { + const config = configFromEnvRecord({}); + expect(() => + azureApiSettingsFromConfig(config, ModelType.Chat, "nonexistent"), + ).toThrow(/No Azure OpenAI endpoint configured/); + }); + + test("apiSettingsFromConfig prefers Azure when both are configured", () => { + const config = configFromEnvRecord({ + OPENAI_API_KEY: "sk-test", + OPENAI_ENDPOINT: "https://api.openai.com/v1/chat/completions", + OPENAI_MODEL: "gpt-4o-mini", + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://azure-wins", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + }); + const s = apiSettingsFromConfig( + config, + ModelType.Chat, + "gpt_4_o", + "eastus", + ); + expect(s.provider).toBe("azure"); + if (s.provider === "azure") { + expect(s.endpoint).toBe("https://azure-wins"); + } + }); + + test("apiSettingsFromConfig falls back to OpenAI when Azure deployment is missing", () => { + const config = configFromEnvRecord({ + OPENAI_API_KEY: "sk-test", + OPENAI_ENDPOINT: "https://api.openai.com/v1/chat/completions", + OPENAI_MODEL: "gpt-4o-mini", + }); + const s = apiSettingsFromConfig(config, ModelType.Chat, "gpt_4_o"); + expect(s.provider).toBe("openai"); + if (s.provider === "openai") { + expect(s.apiKey).toBe("sk-test"); + expect(s.modelName).toBe("gpt-4o-mini"); + } + }); + + test("OpenAI: openAIApiSettingsFromConfig requires endpoint", () => { + const config = configFromEnvRecord({ OPENAI_API_KEY: "sk-test" }); + expect(() => + openAIApiSettingsFromConfig(config, ModelType.Chat), + ).toThrow(/No OpenAI endpoint/); + }); + + test("getDeployment / getDeploymentEndpoint typed lookups", () => { + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://eastus", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_WESTUS: "https://westus", + AZURE_OPENAI_API_KEY_GPT_4_O_WESTUS: "identity", + }); + const dep = getDeployment(config, "gpt_4_o"); + expect(dep).toBeDefined(); + expect(dep!.endpoints.length).toBe(2); + expect( + getDeploymentEndpoint(config, "gpt_4_o", "westus")?.endpoint, + ).toBe("https://westus"); + expect(getDeployment(config, "missing")).toBeUndefined(); + }); + + test("typed-config endpoint matches env-based endpoint for same input", () => { + const env = { + AZURE_OPENAI_ENDPOINT: "https://legacy.example", + AZURE_OPENAI_API_KEY: "legacy-key", + AZURE_OPENAI_MAX_CONCURRENCY: "12", + AZURE_OPENAI_MAX_TIMEOUT: "45000", + }; + const fromEnv = azureApiSettingsFromEnv(ModelType.Chat, env); + const fromConfig = azureApiSettingsFromConfig( + configFromEnvRecord(env), + ModelType.Chat, + ); + expect(fromConfig.endpoint).toBe(fromEnv.endpoint); + expect(fromConfig.apiKey).toBe(fromEnv.apiKey); + expect(fromConfig.maxConcurrency).toBe(fromEnv.maxConcurrency); + expect(fromConfig.timeout).toBe(fromEnv.timeout); + }); +}); diff --git a/ts/packages/aiclient/test/bing.test.ts b/ts/packages/aiclient/test/bing.test.ts index c21eec4997..b97289ae33 100644 --- a/ts/packages/aiclient/test/bing.test.ts +++ b/ts/packages/aiclient/test/bing.test.ts @@ -1,13 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import { testIf } from "./testCore.js"; import * as bing from "../src/bing.js"; -dotenv.config({ - path: new URL("../../../../.env", import.meta.url), -}); +loadConfigSync(); function hasBingApiKey() { try { diff --git a/ts/packages/aiclient/test/endpointPoolFromConfig.spec.ts b/ts/packages/aiclient/test/endpointPoolFromConfig.spec.ts new file mode 100644 index 0000000000..1e4d209b61 --- /dev/null +++ b/ts/packages/aiclient/test/endpointPoolFromConfig.spec.ts @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + configFromEnvRecord, + discoverEndpointPoolFromConfig, +} from "../src/index.js"; +import { ModelType } from "../src/openai.js"; + +describe("discoverEndpointPoolFromConfig: typed-Config endpoint pool", () => { + test("named model, single region: pool of one", () => { + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://eastus", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + }); + const pool = discoverEndpointPoolFromConfig( + config, + "azure", + ModelType.Chat, + "gpt_4_o", + ); + expect(pool.modelKey).toBe("azure:gpt_4_o"); + expect(pool.members).toHaveLength(1); + expect(pool.members[0].suffix).toBe("GPT_4_O_EASTUS"); + expect(pool.members[0].priority).toBe(2); // PAYG default + expect(pool.members[0].mode).toBe("PAYG"); + expect(pool.members[0].region).toBe("eastus"); + }); + + test("named model with PTU + multiple PAYG regions: tiered pool", () => { + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS_PTU: "https://eastus-ptu", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS_PTU: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_SWEDENCENTRAL: "https://sweden", + AZURE_OPENAI_API_KEY_GPT_4_O_SWEDENCENTRAL: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_WESTUS: "https://westus", + AZURE_OPENAI_API_KEY_GPT_4_O_WESTUS: "identity", + }); + const pool = discoverEndpointPoolFromConfig( + config, + "azure", + ModelType.Chat, + "gpt_4_o", + ); + expect(pool.members).toHaveLength(3); + const ptu = pool.members.find((m) => m.mode === "PTU")!; + expect(ptu).toBeDefined(); + expect(ptu.priority).toBe(1); + expect(ptu.suffix).toBe("GPT_4_O_EASTUS_PTU"); + + const payg = pool.members.filter((m) => m.mode === "PAYG"); + expect(payg).toHaveLength(2); + for (const m of payg) { + expect(m.priority).toBe(2); + } + }); + + test("AZURE_OPENAI_POOL_ JSON override (in extras) wins", () => { + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS_PTU: "https://eastus-ptu", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS_PTU: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_SWEDENCENTRAL: "https://sweden", + AZURE_OPENAI_API_KEY_GPT_4_O_SWEDENCENTRAL: "identity", + AZURE_OPENAI_POOL_GPT_4_O: JSON.stringify([ + { + suffix: "GPT_4_O_EASTUS_PTU", + priority: 3, + mode: "PAYG", + }, + { + suffix: "GPT_4_O_SWEDENCENTRAL", + priority: 1, + tpm: 30000, + }, + ]), + }); + const pool = discoverEndpointPoolFromConfig( + config, + "azure", + ModelType.Chat, + "gpt_4_o", + ); + const ptu = pool.members.find((m) => m.region === "eastus")!; + const sweden = pool.members.find((m) => m.region === "swedencentral")!; + expect(ptu).toBeDefined(); + expect(sweden).toBeDefined(); + // Override flipped mode PTU -> PAYG and set priority=3 + expect(ptu.priority).toBe(3); + expect(ptu.mode).toBe("PAYG"); + expect(sweden.priority).toBe(1); + expect(sweden.declaredTpm).toBe(30000); + }); + + test("invalid pool JSON in extras is ignored, no throw", () => { + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://eastus", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + AZURE_OPENAI_POOL_GPT_4_O: "{not valid json", + }); + expect(() => + discoverEndpointPoolFromConfig( + config, + "azure", + ModelType.Chat, + "gpt_4_o", + ), + ).not.toThrow(); + }); + + test("default embedding: bare endpoint produces single-member pool", () => { + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_EMBEDDING: "https://embed-default", + AZURE_OPENAI_API_KEY_EMBEDDING: "identity", + }); + const pool = discoverEndpointPoolFromConfig( + config, + "azure", + ModelType.Embedding, + ); + expect(pool.members).toHaveLength(1); + expect(pool.members[0].suffix).toBe(""); + expect(pool.members[0].priority).toBe(1); + }); + + test("typed-Config pools are isolated by deployment name (no prefix collision)", () => { + // Same "swallowing" hazard as the legacy GPT_4_O / GPT_4_O_MINI test: + // gpt_4_o pool must not accidentally include gpt_4_o_mini members. + // The typed map literally keys deployments by name so this is + // structurally impossible — no heuristic needed. + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://4o-eastus", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_SWEDENCENTRAL_PTU: + "https://4o-sweden-ptu", + AZURE_OPENAI_API_KEY_GPT_4_O_SWEDENCENTRAL_PTU: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_MINI_EASTUS: "https://4o-mini-eastus", + AZURE_OPENAI_API_KEY_GPT_4_O_MINI_EASTUS: "identity", + }); + const pool = discoverEndpointPoolFromConfig( + config, + "azure", + ModelType.Chat, + "gpt_4_o", + ); + const suffixes = pool.members.map((m) => m.suffix).sort(); + expect(suffixes).toEqual([ + "GPT_4_O_EASTUS", + "GPT_4_O_SWEDENCENTRAL_PTU", + ]); + }); + + test("openai provider: single-member pool from config.openAI", () => { + const config = configFromEnvRecord({ + OPENAI_API_KEY: "sk-test", + OPENAI_ENDPOINT: "https://api.openai.com/v1/chat/completions", + }); + const pool = discoverEndpointPoolFromConfig( + config, + "openai", + ModelType.Chat, + ); + expect(pool.modelKey).toBe("openai:"); + expect(pool.members).toHaveLength(1); + expect(pool.members[0].settings.provider).toBe("openai"); + }); + + test("ollama provider: throws", () => { + const config = configFromEnvRecord({}); + expect(() => + discoverEndpointPoolFromConfig(config, "ollama", ModelType.Chat), + ).toThrow(/not applicable to ollama/); + }); + + test("missing deployment surfaces descriptive error from azureApiSettingsFromConfig", () => { + const config = configFromEnvRecord({}); + expect(() => + discoverEndpointPoolFromConfig( + config, + "azure", + ModelType.Chat, + "nonexistent", + ), + ).toThrow(/No Azure OpenAI endpoint configured/); + }); + + test("per-member throttler attached when maxConcurrency is set", () => { + const config = configFromEnvRecord({ + AZURE_OPENAI_MAX_CONCURRENCY: "8", + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://eastus", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + }); + const pool = discoverEndpointPoolFromConfig( + config, + "azure", + ModelType.Chat, + "gpt_4_o", + ); + expect(pool.members[0].settings.throttler).toBeDefined(); + expect(pool.members[0].settings.maxConcurrency).toBe(8); + }); + + test("PTU member is highest priority and pickable first", () => { + // Sanity check that the legacy pickEndpoint runtime works against + // the typed-built pool — proves shape compatibility without + // separately importing pickEndpoint. + const config = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS_PTU: "https://eastus-ptu", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS_PTU: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_WESTUS: "https://westus", + AZURE_OPENAI_API_KEY_GPT_4_O_WESTUS: "identity", + }); + const pool = discoverEndpointPoolFromConfig( + config, + "azure", + ModelType.Chat, + "gpt_4_o", + ); + // pool.members order matches dep.pool which is sorted by priority. + expect(pool.members[0].mode).toBe("PTU"); + expect(pool.members[0].priority).toBeLessThan(pool.members[1].priority); + }); +}); diff --git a/ts/packages/aiclient/test/modelResource.spec.ts b/ts/packages/aiclient/test/modelResource.spec.ts new file mode 100644 index 0000000000..5938028f4b --- /dev/null +++ b/ts/packages/aiclient/test/modelResource.spec.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + getChatModelNames, + setRuntimeConfig, + configFromEnvRecord, +} from "../src/index.js"; +import { _resetRuntimeConfigForTests } from "../src/runtimeConfig.js"; + +describe("getChatModelNames: typed-Config-driven enumeration", () => { + afterEach(() => { + _resetRuntimeConfigForTests(); + }); + + test("enumerates Azure deployment names from typed Config (uppercased)", async () => { + setRuntimeConfig( + configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://4o", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_MINI_WESTUS: "https://4o-mini", + AZURE_OPENAI_API_KEY_GPT_4_O_MINI_WESTUS: "identity", + AZURE_OPENAI_ENDPOINT_O3_SWEDENCENTRAL_PTU: "https://o3", + AZURE_OPENAI_API_KEY_O3_SWEDENCENTRAL_PTU: "identity", + }), + ); + const names = await getChatModelNames(); + // Filter ollama additions out — those depend on a live HTTP probe. + const azure = names.filter((n) => !n.startsWith("ollama:")); + expect(azure.sort()).toEqual(["GPT_4_O", "GPT_4_O_MINI", "O3"].sort()); + }); + + test("OpenAI named variants from extras surface as openai:", async () => { + setRuntimeConfig( + configFromEnvRecord({ + OPENAI_API_KEY_LOCAL: "sk-x", + OPENAI_ENDPOINT_LOCAL: "http://localhost", + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://4o", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + }), + ); + const names = await getChatModelNames(); + expect(names).toContain("openai:LOCAL"); + expect(names).toContain("GPT_4_O"); + }); + + test("regional and PTU variants of one deployment collapse to one name", async () => { + setRuntimeConfig( + configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://e", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_WESTUS: "https://w", + AZURE_OPENAI_API_KEY_GPT_4_O_WESTUS: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_SWEDENCENTRAL_PTU: "https://p", + AZURE_OPENAI_API_KEY_GPT_4_O_SWEDENCENTRAL_PTU: "identity", + }), + ); + const names = await getChatModelNames(); + const azure = names.filter((n) => !n.startsWith("ollama:")); + expect(azure).toEqual(["GPT_4_O"]); + }); +}); diff --git a/ts/packages/aiclient/test/openai.test.ts b/ts/packages/aiclient/test/openai.test.ts index db327117bf..201701206c 100644 --- a/ts/packages/aiclient/test/openai.test.ts +++ b/ts/packages/aiclient/test/openai.test.ts @@ -1,11 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; -dotenv.config({ - path: new URL("../../../../.env", import.meta.url), -}); +loadConfigSync(); import { getData } from "typechat"; import { diff --git a/ts/packages/aiclient/test/runtimeConfig.spec.ts b/ts/packages/aiclient/test/runtimeConfig.spec.ts new file mode 100644 index 0000000000..f55a2b8897 --- /dev/null +++ b/ts/packages/aiclient/test/runtimeConfig.spec.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { _resetRuntimeConfigForTests } from "../src/runtimeConfig.js"; +import { + getRuntimeConfig, + initRuntimeConfigFromProcessEnv, + setRuntimeConfig, + configFromEnvRecord, +} from "../src/index.js"; + +describe("runtimeConfig: process-wide singleton", () => { + beforeEach(() => { + _resetRuntimeConfigForTests(); + }); + + test("getRuntimeConfig lazily builds from process.env on first access", () => { + const config = getRuntimeConfig(); + expect(config).toBeDefined(); + expect(config.azureOpenAI).toBeDefined(); + // Subsequent calls return the same cached instance. + expect(getRuntimeConfig()).toBe(config); + }); + + test("setRuntimeConfig pins a curated Config", () => { + const pinned = configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://pinned", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + }); + setRuntimeConfig(pinned); + const got = getRuntimeConfig(); + expect(got).toBe(pinned); + expect(got.azureOpenAI.deployments.get("gpt_4_o")).toBeDefined(); + }); + + test("initRuntimeConfigFromProcessEnv overrides cached value", () => { + setRuntimeConfig(configFromEnvRecord({})); + const fresh = initRuntimeConfigFromProcessEnv(); + expect(getRuntimeConfig()).toBe(fresh); + }); +}); diff --git a/ts/packages/api/package.json b/ts/packages/api/package.json index cfa6052cf0..f0016d15d3 100644 --- a/ts/packages/api/package.json +++ b/ts/packages/api/package.json @@ -32,6 +32,7 @@ "@azure/storage-blob": "^12.26.0", "@typeagent/agent-rpc": "workspace:*", "@typeagent/agent-sdk": "workspace:*", + "@typeagent/config": "workspace:*", "@typeagent/dispatcher-rpc": "workspace:*", "agent-cache": "workspace:*", "agent-dispatcher": "workspace:*", diff --git a/ts/packages/api/src/index.ts b/ts/packages/api/src/index.ts index c5f345abff..43c13bbdf4 100644 --- a/ts/packages/api/src/index.ts +++ b/ts/packages/api/src/index.ts @@ -2,11 +2,10 @@ // Licensed under the MIT License. import { TypeAgentServer } from "./typeAgentServer.js"; -import findConfig from "find-config"; -import assert from "assert"; +import { loadConfig } from "@typeagent/config"; -const envPath = findConfig(".env"); -assert(envPath, ".env file not found!"); +// Load config from YAML layers + Key Vault (replacing legacy dotenv). +await loadConfig({ keyVault: {}, strict: false }); -const typeAgentServer: TypeAgentServer = new TypeAgentServer(envPath); +const typeAgentServer: TypeAgentServer = new TypeAgentServer(); typeAgentServer.start(); diff --git a/ts/packages/api/src/typeAgentServer.ts b/ts/packages/api/src/typeAgentServer.ts index 8fc6745d73..c81fe204fb 100644 --- a/ts/packages/api/src/typeAgentServer.ts +++ b/ts/packages/api/src/typeAgentServer.ts @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation and Henry Lucco. // Licensed under the MIT License. -import dotenv from "dotenv"; import { getUserDataDir } from "agent-dispatcher/helpers/data"; import { readFileSync } from "node:fs"; +import { initRuntimeConfigFromProcessEnv } from "aiclient"; import { TypeAgentAPIServerConfig, TypeAgentAPIWebServer, @@ -37,11 +37,12 @@ export class TypeAgentServer { private storageProvider: TypeAgentStorageProvider | undefined; private config: TypeAgentAPIServerConfig; - constructor(private envPath: string) { - debug(`Loading .env from path: ${envPath}`); - - // typeAgent config - dotenv.config({ path: this.envPath }); + constructor() { + // Build typed runtime Config from process.env (already populated + // by loadConfig in the entry point) so aiclient consumers can + // use the typed accessor; legacy callers still see the same + // values through process.env directly. + initRuntimeConfigFromProcessEnv(); // web server config this.config = JSON.parse(readFileSync("data/config.json").toString()); diff --git a/ts/packages/api/test/api.spec.ts b/ts/packages/api/test/api.spec.ts index 7919e72454..8d897dd4aa 100644 --- a/ts/packages/api/test/api.spec.ts +++ b/ts/packages/api/test/api.spec.ts @@ -1,34 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import assert from "assert"; import { TypeAgentServer } from "../src/typeAgentServer.js"; -import findConfig from "find-config"; describe.skip("api web/ws server", () => { it("verify web server respnses", async () => { - const envPath = findConfig(".env"); - if (envPath !== null) { - assert(envPath, ".env file not found!"); - const typeAgentServer: TypeAgentServer = new TypeAgentServer( - envPath!, - ); - await typeAgentServer.start(); + const typeAgentServer: TypeAgentServer = new TypeAgentServer(); + await typeAgentServer.start(); - let response = await fetch("http://localhost:3000/"); - expect(response.ok); + let response = await fetch("http://localhost:3000/"); + expect(response.ok); - response = await fetch("http://localhost:3000/index.html"); - expect(response.ok); + response = await fetch("http://localhost:3000/index.html"); + expect(response.ok); - response = await fetch("http://localhost:3000/sdfadfs.asdfsdf"); - expect(response.status == 404); + response = await fetch("http://localhost:3000/sdfadfs.asdfsdf"); + expect(response.status == 404); - typeAgentServer.stop(); - } else { - console.warn( - "Skipping test 'verify web server respnses', no .env file!", - ); - } + typeAgentServer.stop(); }); }); diff --git a/ts/packages/azure-ai-foundry/package.json b/ts/packages/azure-ai-foundry/package.json index 591dbf3c46..ff023d88c4 100644 --- a/ts/packages/azure-ai-foundry/package.json +++ b/ts/packages/azure-ai-foundry/package.json @@ -37,6 +37,7 @@ "@azure/ai-projects": "^1.0.0-beta.8", "@azure/identity": "^4.10.0", "@typeagent/agent-sdk": "workspace:*", + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "async": "^3.2.5", "debug": "^4.4.0", diff --git a/ts/packages/azure-ai-foundry/test/urlResolver.spec.ts b/ts/packages/azure-ai-foundry/test/urlResolver.spec.ts index 5d1ccd8809..96aee8fb2c 100644 --- a/ts/packages/azure-ai-foundry/test/urlResolver.spec.ts +++ b/ts/packages/azure-ai-foundry/test/urlResolver.spec.ts @@ -1,13 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import * as urlResolver from "../src/urlResolver.js"; import { bingWithGrounding } from "../src/index.js"; -dotenv.config({ - path: new URL("../../../../.env", import.meta.url), -}); +loadConfigSync(); function testIf( runIf: () => boolean, diff --git a/ts/packages/cli/bin/dev.js b/ts/packages/cli/bin/dev.js index 52be514bb3..f5dd5429db 100755 --- a/ts/packages/cli/bin/dev.js +++ b/ts/packages/cli/bin/dev.js @@ -2,8 +2,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); // eslint-disable-next-line node/shebang async function main() { diff --git a/ts/packages/cli/bin/run.js b/ts/packages/cli/bin/run.js index 21a014b39c..43997f76dd 100755 --- a/ts/packages/cli/bin/run.js +++ b/ts/packages/cli/bin/run.js @@ -2,8 +2,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); async function main() { const { execute } = await import("@oclif/core"); diff --git a/ts/packages/cli/package.json b/ts/packages/cli/package.json index 4e66a6fde8..158b66f591 100644 --- a/ts/packages/cli/package.json +++ b/ts/packages/cli/package.json @@ -56,6 +56,7 @@ "@typeagent/agent-sdk": "workspace:*", "@typeagent/agent-server-client": "workspace:*", "@typeagent/common-utils": "workspace:*", + "@typeagent/config": "workspace:*", "@typeagent/dispatcher-types": "workspace:*", "action-grammar": "workspace:*", "agent-cache": "workspace:*", diff --git a/ts/packages/commandExecutor/package.json b/ts/packages/commandExecutor/package.json index 2539570dac..5e5d58bf65 100644 --- a/ts/packages/commandExecutor/package.json +++ b/ts/packages/commandExecutor/package.json @@ -31,6 +31,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@typeagent/agent-sdk": "workspace:*", "@typeagent/agent-server-client": "workspace:*", + "@typeagent/config": "workspace:*", "@typeagent/dispatcher-types": "workspace:*", "dotenv": "^16.3.1", "html-to-text": "^9.0.5", diff --git a/ts/packages/commandExecutor/src/server.ts b/ts/packages/commandExecutor/src/server.ts index b8584d0f47..ba78bb225a 100644 --- a/ts/packages/commandExecutor/src/server.ts +++ b/ts/packages/commandExecutor/src/server.ts @@ -2,10 +2,10 @@ // Licensed under the MIT License. import { CommandServer } from "./commandServer.js"; -import dotenv from "dotenv"; +import { loadConfig } from "@typeagent/config"; -const envPath = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envPath }); +// Load config from YAML layers + Key Vault (replacing legacy dotenv). +await loadConfig({ keyVault: {}, strict: false }); console.log("Starting Command Executor Server"); diff --git a/ts/packages/config/LICENSE b/ts/packages/config/LICENSE new file mode 100644 index 0000000000..7303776f31 --- /dev/null +++ b/ts/packages/config/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. diff --git a/ts/packages/config/README.md b/ts/packages/config/README.md new file mode 100644 index 0000000000..36b3f14a5e --- /dev/null +++ b/ts/packages/config/README.md @@ -0,0 +1,60 @@ +# @typeagent/config + +Layered YAML configuration loader for TypeAgent. + +## Status + +Phase 1 of the YAML config migration. This phase introduces: + +- A YAML loader that reads `ts/config.defaults.yaml` (committed) and + `ts/config.local.yaml` (gitignored). +- A flattener that produces flat `KEY=value` pairs matching the existing + `EnvVars` enum convention used by `aiclient`, so existing + `getEnvSetting` / `process.env` consumers keep working unchanged. +- A `.env` fallback (lowest precedence) for backwards compatibility. +- Lightweight schema validation via `zod`. + +Live Azure Key Vault fetch, encrypted on-disk caching, and the +`typeagent config` CLI family are added in later phases. + +## Merge precedence (low → high) + +1. `.env` (legacy fallback, deprecated) +2. `ts/config.defaults.yaml` +3. _Future:_ Key Vault YAML blob (or encrypted cache) +4. `ts/config.local.yaml` +5. `process.env` (caller-provided overrides) + +## Flattening rules + +YAML maps are flattened into the `EnvVars` flat-key shape used by +[packages/aiclient/src/openai.ts](../aiclient/src/openai.ts): + +| YAML path | Flat key | +| -------------------------------------------------- | -------------------------------------- | +| `azure.openai.api_key` | `AZURE_OPENAI_API_KEY` | +| `azure.openai.endpoint` | `AZURE_OPENAI_ENDPOINT` | +| `azure.openai.deployments[].endpoint` (suffix=foo) | `AZURE_OPENAI_ENDPOINT_FOO` | +| `openai.api_key` | `OPENAI_API_KEY` | +| `bing.api_key` | `BING_API_KEY` | +| `extra.` | `` (passthrough for unknown keys) | + +See [src/flatten.ts](./src/flatten.ts) for the full mapping. + +## Usage + +```ts +import { loadConfig } from "@typeagent/config"; + +await loadConfig(); +// process.env is now populated from YAML + .env fallback. +// Existing aiclient code works unchanged. +``` + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/ts/packages/config/bin/typeagent-config.mjs b/ts/packages/config/bin/typeagent-config.mjs new file mode 100644 index 0000000000..1942e6faae --- /dev/null +++ b/ts/packages/config/bin/typeagent-config.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { runCli } from "../dist/cli.js"; + +const code = await runCli(process.argv.slice(2)); +process.exit(code); diff --git a/ts/packages/config/jest.config.cjs b/ts/packages/config/jest.config.cjs new file mode 100644 index 0000000000..25456e93bb --- /dev/null +++ b/ts/packages/config/jest.config.cjs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +module.exports = require("../../jest.config.js"); diff --git a/ts/packages/config/package.json b/ts/packages/config/package.json new file mode 100644 index 0000000000..595dfc0792 --- /dev/null +++ b/ts/packages/config/package.json @@ -0,0 +1,57 @@ +{ + "name": "@typeagent/config", + "version": "0.0.1", + "description": "Layered YAML configuration loader for TypeAgent (defaults + Key Vault + local overrides + .env fallback).", + "homepage": "https://github.com/microsoft/TypeAgent#readme", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/TypeAgent.git", + "directory": "ts/packages/config" + }, + "license": "MIT", + "author": "Microsoft", + "type": "module", + "exports": { + ".": "./dist/index.js", + "./cli": "./dist/cli.js" + }, + "types": "./dist/index.d.ts", + "bin": { + "typeagent-config": "./bin/typeagent-config.mjs" + }, + "files": [ + "bin", + "dist", + "!dist/test" + ], + "scripts": { + "build": "npm run tsc", + "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", + "jest-esm": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", + "prettier": "prettier --check . --ignore-path ../../.prettierignore", + "prettier:fix": "prettier --write . --ignore-path ../../prettierignore", + "test": "npm run test:local", + "test:live": "echo \"no live tests in phase 1\"", + "test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\"", + "tsc": "tsc -b" + }, + "dependencies": { + "@azure/identity": "^4.10.0", + "@azure/keyvault-secrets": "^4.9.0", + "debug": "^4.4.0", + "dotenv": "^16.3.1", + "js-yaml": "^4.1.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/debug": "^4.1.12", + "@types/jest": "^29.5.7", + "@types/js-yaml": "^4.0.9", + "jest": "^29.7.0", + "rimraf": "^6.0.1", + "typescript": "~5.4.5" + }, + "engines": { + "node": ">=20" + } +} diff --git a/ts/packages/config/src/cli.ts b/ts/packages/config/src/cli.ts new file mode 100644 index 0000000000..af05fcf962 --- /dev/null +++ b/ts/packages/config/src/cli.ts @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import yaml from "js-yaml"; + +import { loadConfig } from "./loader.js"; +import { + importDotEnv, + writeConfigYamlFile, + type ImportResult, +} from "./import.js"; +import { redactFlat, redactTree } from "./redact.js"; +import { fetchKeyVaultConfig } from "./keyVault.js"; +import { DefaultAzureCredential, type TokenCredential } from "@azure/identity"; +import type { ConfigSource, KeyVaultOptions } from "./types.js"; + +/** + * Stream-like sink used by the CLI runner. Tests substitute their + * own implementation to capture output without touching the real + * `process.stdout` / `process.stderr`. + */ +export interface CliIO { + stdout: (text: string) => void; + stderr: (text: string) => void; +} + +const consoleIO: CliIO = { + stdout: (t) => process.stdout.write(t), + stderr: (t) => process.stderr.write(t), +}; + +/** + * Argument tuple accepted by the CLI runner. Mirrors `process.argv` + * with the leading `node` and script-path arguments stripped. + */ +export type CliArgs = string[]; + +const HELP = `\ +typeagent-config [options] + +Commands: + import [--out ] Convert a .env file to YAML. + Verifies a lossless round-trip. + --out defaults to ./config.local.yaml + show [--source] [--reveal-secrets] Print the merged config (redacted by + default). --source annotates each + key with its origin layer. + check [--vault ] [--secret ] Verify Azure auth + Key Vault + reachability. Returns exit code 0 + on success, 1 on failure. + --help, -h Show this message. + +Environment: + Most commands honor TYPEAGENT_CONFIG_VAULT and + TYPEAGENT_CONFIG_SECRET as defaults for --vault and --secret. +`; + +/** + * Run the CLI. Returns a numeric exit code (0 = success). Designed to + * be called from a thin bin shim or directly from tests. + */ +export async function runCli( + argv: CliArgs, + io: CliIO = consoleIO, +): Promise { + const [cmd, ...rest] = argv; + if (!cmd || cmd === "--help" || cmd === "-h") { + io.stdout(HELP); + return cmd ? 0 : 1; + } + + try { + switch (cmd) { + case "import": + return await runImport(rest, io); + case "show": + return await runShow(rest, io); + case "check": + return await runCheck(rest, io); + default: + io.stderr(`Unknown command: ${cmd}\n`); + io.stdout(HELP); + return 1; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + io.stderr(`Error: ${msg}\n`); + return 1; + } +} + +// --------------------------------------------------------------------------- +// import +// --------------------------------------------------------------------------- + +async function runImport(args: string[], io: CliIO): Promise { + const positional: string[] = []; + let outPath: string | undefined; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--out") { + outPath = args[++i]; + } else if (a.startsWith("--")) { + io.stderr(`Unknown flag: ${a}\n`); + return 1; + } else { + positional.push(a); + } + } + if (positional.length !== 1) { + io.stderr( + `Usage: typeagent-config import [--out ]\n`, + ); + return 1; + } + const inputPath = positional[0]; + if (!fs.existsSync(inputPath)) { + io.stderr(`File not found: ${inputPath}\n`); + return 1; + } + const result: ImportResult = importDotEnv(inputPath); + + const target = outPath ?? path.resolve("config.local.yaml"); + writeConfigYamlFile( + target, + result.tree, + `# Imported from ${path.relative(process.cwd(), inputPath) || inputPath}\n` + + `# This file is gitignored — never commit secrets.\n`, + ); + + io.stdout( + `Imported ${result.counts.total} key(s) → ${target}\n` + + ` structured: ${result.counts.structured}\n` + + ` extras: ${result.counts.extras}\n` + + (result.intentionalRewrites.length > 0 + ? ` rewrites: ${result.intentionalRewrites.length}\n` + : "") + + `Round-trip verified.\n`, + ); + return 0; +} + +// --------------------------------------------------------------------------- +// show +// --------------------------------------------------------------------------- + +async function runShow(args: string[], io: CliIO): Promise { + let showSource = false; + let revealSecrets = false; + for (const a of args) { + if (a === "--source") showSource = true; + else if (a === "--reveal-secrets") revealSecrets = true; + else if (a === "--help" || a === "-h") { + io.stdout( + `Usage: typeagent-config show [--source] [--reveal-secrets]\n`, + ); + return 0; + } else { + io.stderr(`Unknown flag: ${a}\n`); + return 1; + } + } + + const result = await loadConfig({ + populateProcessEnv: false, + trackSources: showSource, + strict: false, + }); + + const flat = revealSecrets ? result.env : redactFlat(result.env); + const keys = Object.keys(flat).sort(); + + if (showSource && result.sources) { + const sources = result.sources; + for (const k of keys) { + const src = sources[k] ?? ("?" as ConfigSource); + io.stdout(`${k}=${flat[k]} [${src}]\n`); + } + } else { + for (const k of keys) { + io.stdout(`${k}=${flat[k]}\n`); + } + } + return 0; +} + +// --------------------------------------------------------------------------- +// check +// --------------------------------------------------------------------------- + +interface CheckOptions { + vault?: string; + secret?: string; + credential?: TokenCredential; +} + +async function runCheck(args: string[], io: CliIO): Promise { + const opts: CheckOptions = {}; + if (process.env.TYPEAGENT_CONFIG_VAULT) { + opts.vault = process.env.TYPEAGENT_CONFIG_VAULT; + } + if (process.env.TYPEAGENT_CONFIG_SECRET) { + opts.secret = process.env.TYPEAGENT_CONFIG_SECRET; + } + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--vault") opts.vault = args[++i]; + else if (a === "--secret") opts.secret = args[++i]; + else if (a === "--help" || a === "-h") { + io.stdout( + `Usage: typeagent-config check [--vault ] [--secret ]\n`, + ); + return 0; + } else { + io.stderr(`Unknown flag: ${a}\n`); + return 1; + } + } + if (!opts.vault) { + io.stderr( + `Vault name required. Pass --vault or set TYPEAGENT_CONFIG_VAULT.\n`, + ); + return 1; + } + + const credential = opts.credential ?? new DefaultAzureCredential(); + const kvOptions: KeyVaultOptions = { + vaultName: opts.vault, + credential, + failOnError: true, + }; + if (opts.secret !== undefined) { + kvOptions.secretName = opts.secret; + } + + io.stdout(`Checking Key Vault ${opts.vault}…\n`); + try { + const tree = await fetchKeyVaultConfig(kvOptions); + if (tree === null) { + io.stderr( + `Auth OK, but secret '${opts.secret ?? "typeagent-config"}' not found in vault '${opts.vault}'.\n`, + ); + return 1; + } + const flat = redactTree(tree); + const summary = yaml.dump(flat, { indent: 2, sortKeys: true }); + io.stdout(`OK. Resolved ${countLeaves(tree)} value(s).\n`); + io.stdout(`Redacted preview:\n${summary}`); + return 0; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + io.stderr(`FAIL: ${msg}\n`); + return 1; + } +} + +function countLeaves(node: unknown): number { + if (node === null || node === undefined) return 0; + if (typeof node !== "object") return 1; + if (Array.isArray(node)) { + return node.reduce((acc, item) => acc + countLeaves(item), 0); + } + let n = 0; + for (const v of Object.values(node as Record)) { + n += countLeaves(v); + } + return n; +} diff --git a/ts/packages/config/src/flatten.ts b/ts/packages/config/src/flatten.ts new file mode 100644 index 0000000000..8357a49822 --- /dev/null +++ b/ts/packages/config/src/flatten.ts @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ConfigTree, FlatEnv } from "./types.js"; +import { isTypedSectionKey, typedSectionToFlat } from "./runtime/tree.js"; + +/** + * Top-level keys whose contents are passed through verbatim into the + * flat env namespace. Used for keys that already follow the flat + * `EnvVars` convention exactly (e.g., raw `.env` content rendered as + * YAML by the importer). + */ +const PASSTHROUGH_KEYS = new Set(["env", "extra"]); + +/** + * Top-level shorthand keys whose value is an array of bare env-var + * names. Each listed name is emitted into the flat env with the + * shorthand key's own name as the value. Lets users write: + * + * identity: + * - AZURE_OPENAI_API_KEY + * - AZURE_OPENAI_API_KEY_GPT_IMAGE_1_5 + * + * instead of repeating `: identity` on every line. + */ +const VALUE_GROUP_KEYS = new Set(["identity"]); + +/** + * Flatten a parsed YAML configuration tree into a flat env-var map of + * the shape consumed by `aiclient`'s `getEnvSetting` and the rest of + * TypeAgent's existing `process.env`-based code. + * + * Rules (Phase 1): + * + * - Nested map paths are joined with `_` and uppercased, so + * `azure.openai.endpoint` becomes `AZURE_OPENAI_ENDPOINT`. + * - Top-level keys named `env` and `extra` are passed through verbatim + * — their child keys are written into the flat env exactly as + * spelled. This is the lowest-friction migration form for users + * converting an existing `.env`. + * - Booleans become `"1"` (true) or are dropped (false), matching the + * `AZURE_OPENAI_RESPONSE_FORMAT=1` convention already established in + * the codebase. + * - Numbers are stringified. + * - `null` and `undefined` values are dropped (they signal "unset"). + * - Arrays are not supported in Phase 1 and produce a descriptive + * error pointing the caller at the future structured schema. + */ +export function flatten(tree: ConfigTree | null | undefined): FlatEnv { + if (tree === null || tree === undefined) { + return {}; + } + const out: FlatEnv = {}; + walk(tree, [], out, /*passthrough*/ false); + return out; +} + +function walk( + node: unknown, + path: string[], + out: FlatEnv, + passthrough: boolean, +): void { + if (node === null || node === undefined) { + return; + } + + if (Array.isArray(node)) { + throw new Error( + `Arrays are not supported by the Phase 1 flattener at ` + + `'${path.join(".")}'. Use the 'env:' passthrough form ` + + `(flat KEY: value pairs) or wait for the structured ` + + `deployment schema introduced by the importer.`, + ); + } + + if (typeof node === "object") { + for (const [rawKey, value] of Object.entries( + node as Record, + )) { + if (path.length === 0 && VALUE_GROUP_KEYS.has(rawKey)) { + expandValueGroup(rawKey, value, out); + continue; + } + if (path.length === 0 && isTypedSectionKey(rawKey)) { + const sub = typedSectionToFlat(rawKey, value); + for (const [k, v] of Object.entries(sub)) { + out[k] = v; + } + continue; + } + const isPassthroughBoundary = + path.length === 0 && PASSTHROUGH_KEYS.has(rawKey); + walk( + value, + isPassthroughBoundary ? path : [...path, rawKey], + out, + passthrough || isPassthroughBoundary, + ); + } + return; + } + + // Leaf scalar. + const flatKey = passthrough ? path.join("_") : toEnvKey(path); + if (!flatKey) { + return; + } + const stringValue = scalarToString(node); + if (stringValue !== undefined) { + out[flatKey] = stringValue; + } +} + +function toEnvKey(path: string[]): string { + // Join with underscore and uppercase. Each segment may already + // contain underscores; we keep them as-is. + return path.join("_").toUpperCase(); +} + +function expandValueGroup( + groupName: string, + value: unknown, + out: FlatEnv, +): void { + if (!Array.isArray(value)) { + throw new Error( + `Top-level '${groupName}:' must be an array of env-var ` + + `names (each will be set to '${groupName}').`, + ); + } + for (const entry of value) { + if (typeof entry !== "string" || entry.length === 0) { + throw new Error( + `'${groupName}:' entries must be non-empty strings; ` + + `got ${JSON.stringify(entry)}.`, + ); + } + out[entry] = groupName; + } +} + +function scalarToString(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return undefined; + } + return String(value); + } + if (typeof value === "boolean") { + // Match the codebase convention: truthy flags are stored as + // "1"; falsy flags are simply absent. + return value ? "1" : undefined; + } + return undefined; +} + +/** + * Merge two flat env maps. Later wins. Returned object is a fresh + * shallow copy; inputs are not mutated. + */ +export function mergeFlat(...maps: FlatEnv[]): FlatEnv { + const out: FlatEnv = {}; + for (const m of maps) { + for (const k of Object.keys(m)) { + out[k] = m[k]; + } + } + return out; +} diff --git a/ts/packages/config/src/import.ts b/ts/packages/config/src/import.ts new file mode 100644 index 0000000000..2bed99010a --- /dev/null +++ b/ts/packages/config/src/import.ts @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import yaml from "js-yaml"; +import dotenv from "dotenv"; +import registerDebug from "debug"; + +import { flatten } from "./flatten.js"; +import { buildConfig } from "./runtime/build.js"; +import { configToEnv } from "./runtime/shim.js"; +import { configToTree } from "./runtime/tree.js"; +import type { ConfigTree, FlatEnv } from "./types.js"; + +const debug = registerDebug("typeagent:config:import"); + +/** + * Result of importing a flat `.env` file. The `tree` field contains + * the YAML-shaped configuration ready to be written to disk; the + * `roundTrip` field is the result of re-flattening that tree, used + * by the verification step to prove no information was lost. + */ +export interface ImportResult { + /** Parsed-and-restructured configuration tree. */ + tree: ConfigTree; + /** + * Re-flattened form of `tree`. When the importer is correct, this + * equals the input flat map (modulo any keys flagged in + * `intentionalRewrites`). + */ + roundTrip: FlatEnv; + /** + * Number of keys placed under each top-level bucket. Useful for + * humans reading the import summary. + */ + counts: { structured: number; extras: number; total: number }; + /** + * Keys that were rewritten away from their literal flat form (for + * example `AZURE_OPENAI_API_KEY=identity` becoming `auth: identity` + * in a future iteration). Excluded from the round-trip diff. + */ + intentionalRewrites: string[]; +} + +/** + * Parse a `.env` file at `filePath` into a flat env map, mirroring + * `dotenv.config()` behavior but without mutating `process.env`. + */ +export function parseDotEnvFile(filePath: string): FlatEnv { + const raw = fs.readFileSync(filePath, "utf8"); + return parseDotEnvText(raw); +} + +/** + * Parse a `.env` text blob into a flat env map. + */ +export function parseDotEnvText(text: string): FlatEnv { + const parsed = dotenv.parse(text); + const out: FlatEnv = {}; + for (const [k, v] of Object.entries(parsed)) { + // dotenv yields strings already; preserve as-is. + out[k] = v; + } + return out; +} + +/** + * Convert a flat env map into a `ConfigTree` suitable for writing as + * YAML. The importer runs `buildConfig` to lift well-known env-var + * conventions (Azure OpenAI deployments, Speech, Maps, etc.) into the + * typed `Config` shape, then projects that back to the YAML tree via + * `configToTree`. Anything `buildConfig` didn't recognize lands in + * `extra:` verbatim, with `"identity"`-valued keys hoisted into the + * `identity:` shorthand list. + */ +export function flatEnvToConfigTree(flat: FlatEnv): ConfigTree { + const config = buildConfig(flat); + const tree: ConfigTree = configToTree(config); + + // Partition Config.extra into identity-shorthand vs. true extras. + const identity: string[] = []; + const extras: Record = {}; + for (const [k, v] of config.extra) { + if (v === "identity") { + identity.push(k); + } else { + extras[k] = v; + } + } + if (identity.length > 0) { + identity.sort(); + tree.identity = identity; + } + if (Object.keys(extras).length > 0) { + tree.extra = extras; + } + return tree; +} + +/** + * Run the full importer: read a `.env` file, build a `ConfigTree`, + * verify round-trip equivalence, and return the result. + * + * Throws when round-trip verification fails — the caller's input is + * truly something we cannot represent losslessly, and silently + * dropping data here would defeat the whole point of the importer. + */ +export function importDotEnv(filePath: string): ImportResult { + debug("importing %s", filePath); + const flat = parseDotEnvFile(filePath); + const tree = flatEnvToConfigTree(flat); + const roundTrip = flatten(tree); + + // Verify: every input key must round-trip into the equivalent + // flat env. Typed sections normalize values (booleans -> "1"/"0", + // identity-keyed endpoints inherit defaultAuth, etc.), so we + // compare against the canonical projection of the rebuilt Config + // rather than the raw input map. If the rebuilt config produces + // the same env as the input config does, no information was lost. + const canonicalInput = configToEnv(buildConfig(flat)); + const intentionalRewrites: string[] = []; + const missing: string[] = []; + const wrong: string[] = []; + for (const [k, v] of Object.entries(canonicalInput)) { + if (intentionalRewrites.includes(k)) continue; + if (!(k in roundTrip)) { + missing.push(k); + } else if (roundTrip[k] !== v) { + wrong.push(k); + } + } + if (missing.length > 0 || wrong.length > 0) { + throw new Error( + `Importer round-trip verification failed. ` + + (missing.length > 0 + ? `Missing keys: ${missing.slice(0, 5).join(", ")}` + + (missing.length > 5 ? `, ...` : "") + + `. ` + : "") + + (wrong.length > 0 + ? `Mismatched values: ${wrong.slice(0, 5).join(", ")}` + + (wrong.length > 5 ? `, ...` : "") + + `.` + : ""), + ); + } + + const extras = (tree.extra as Record | undefined) ?? {}; + const identity = (tree.identity as string[] | undefined) ?? []; + const structured = Object.keys(tree).filter( + (k) => k !== "extra" && k !== "identity", + ).length; + return { + tree, + roundTrip, + counts: { + structured: structured + identity.length, + extras: Object.keys(extras).length, + total: Object.keys(flat).length, + }, + intentionalRewrites, + }; +} + +/** + * Serialize a `ConfigTree` to YAML and write it to disk. Creates + * intermediate directories as needed. The output uses block style + * with a 2-space indent for human-friendly diffs. + */ +export function writeConfigYamlFile( + filePath: string, + tree: ConfigTree, + header?: string, +): void { + const body = yaml.dump(tree, { + indent: 2, + lineWidth: 120, + sortKeys: true, + noRefs: true, + }); + const text = header ? `${header.trimEnd()}\n${body}` : body; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, text, { encoding: "utf8" }); +} diff --git a/ts/packages/config/src/index.ts b/ts/packages/config/src/index.ts new file mode 100644 index 0000000000..1ee6c69a75 --- /dev/null +++ b/ts/packages/config/src/index.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { loadConfig, loadConfigSync } from "./loader.js"; +export { flatten, mergeFlat } from "./flatten.js"; +export { + fetchKeyVaultConfig, + makeAzureFetcher, + DEFAULT_SECRET_NAME, + type KeyVaultFetcher, +} from "./keyVault.js"; +export { + importDotEnv, + parseDotEnvFile, + parseDotEnvText, + flatEnvToConfigTree, + writeConfigYamlFile, + type ImportResult, +} from "./import.js"; +export { + redactFlat, + redactTree, + shouldRedact, + SECRET_KEY_PATTERN, + REDACTED, +} from "./redact.js"; +export { runCli, type CliIO, type CliArgs } from "./cli.js"; +export { validateConfigTree, configTreeSchema } from "./schema.js"; +export { + ConfigSource, + type ConfigScalar, + type ConfigTree, + type FlatEnv, + type KeyVaultOptions, + type LoadConfigOptions, + type LoadConfigResult, + type SourceMap, +} from "./types.js"; + +// Typed runtime config (Phase A: schema + builder + shim). +export { + REGIONS, + isRegion, + regionToEnvSuffix, + regionFromEnvSuffix, + type Region, +} from "./runtime/regions.js"; +export { + IDENTITY, + authModeFromString, + type AuthMode, + type AzureOpenAIConfig, + type AzureStorageConfig, + type AwsStorageConfig, + type Config, + type DatabaseConfig, + type Deployment, + type DeploymentEndpoint, + type DeploymentMode, + type GoogleCalendarConfig, + type MapsConfig, + type MicrosoftGraphConfig, + type OpenAIConfig, + type SpeechConfig, + type SpotifyConfig, + type StorageConfig, + type VaultConfig, + type WikipediaConfig, +} from "./runtime/types.js"; +export { buildConfig, parseSuffix } from "./runtime/build.js"; +export { + configToEnv, + applyToProcessEnv, + type EnvOutput, +} from "./runtime/shim.js"; +export { envToYamlTree } from "./runtime/tree.js"; +export { + loadRuntimeConfigSync, + type LoadRuntimeConfigOptions, + type RuntimeConfigResult, +} from "./runtime/load.js"; diff --git a/ts/packages/config/src/keyVault.ts b/ts/packages/config/src/keyVault.ts new file mode 100644 index 0000000000..96993b69ee --- /dev/null +++ b/ts/packages/config/src/keyVault.ts @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import yaml from "js-yaml"; +import registerDebug from "debug"; +import { DefaultAzureCredential, type TokenCredential } from "@azure/identity"; +import { SecretClient } from "@azure/keyvault-secrets"; + +import { validateConfigTree } from "./schema.js"; +import type { ConfigTree, KeyVaultOptions } from "./types.js"; + +const debug = registerDebug("typeagent:config:keyvault"); + +/** Default Key Vault secret name holding the YAML configuration blob. */ +export const DEFAULT_SECRET_NAME = "typeagent-config"; + +/** Maximum size of a single Key Vault secret value (Azure-enforced). */ +const MAX_SECRET_BYTES = 25 * 1024; + +/** + * A `fetcher` that returns the raw secret string for a given vault and + * secret name, or `null` if the secret does not exist. Provided so + * tests can substitute the live Azure SDK call. + */ +export type KeyVaultFetcher = ( + vaultName: string, + secretName: string, +) => Promise; + +/** + * Build a `KeyVaultFetcher` backed by the live Azure SDK. Credentials + * fall through `DefaultAzureCredential`, matching the pattern used in + * [packages/aiclient/src/auth.ts](../../aiclient/src/auth.ts) and the + * `tools/scripts/getKeys.mjs` administrative script. + */ +export function makeAzureFetcher( + credential: TokenCredential = new DefaultAzureCredential(), +): KeyVaultFetcher { + const clients = new Map(); + return async (vaultName, secretName) => { + let client = clients.get(vaultName); + if (!client) { + client = new SecretClient( + `https://${vaultName}.vault.azure.net`, + credential, + ); + clients.set(vaultName, client); + } + try { + const result = await client.getSecret(secretName); + return result.value ?? null; + } catch (e) { + // The Key Vault SDK throws a RestError-shaped object on + // 404; treat "not found" as a soft miss so the loader can + // fall through to other layers. + if ( + typeof e === "object" && + e !== null && + (e as { statusCode?: number }).statusCode === 404 + ) { + return null; + } + throw e; + } + }; +} + +/** + * Are we running inside Jest? Used by the test-isolation guard so + * unit-test runs (`*.spec.ts`, `pnpm test:local`) never make live + * Key Vault calls. Live integration tests (`*.test.ts`, + * `pnpm test:live`) opt in by setting + * `TYPEAGENT_ALLOW_KEYVAULT_IN_TESTS=1`. + */ +function inJest(): boolean { + return ( + process.env.JEST_WORKER_ID !== undefined || + process.env.NODE_ENV === "test" + ); +} + +function keyVaultAllowedInTests(): boolean { + const v = process.env.TYPEAGENT_ALLOW_KEYVAULT_IN_TESTS; + return v === "1" || v?.toLowerCase() === "true"; +} + +/** + * Fetch and parse a TypeAgent YAML configuration blob from Azure + * Key Vault. Returns `null` when: + * + * - The secret does not exist (404). + * - The secret exists but is empty. + * - The fetch fails and `failOnError` is false (default — the loader + * chain treats this as cache-miss / fall through to other layers). + * + * Refuses to make a live call when running under Jest unless the + * caller has set `TYPEAGENT_ALLOW_KEYVAULT_IN_TESTS=1` or supplied a + * custom `fetcher` (the test substitution path). + */ +export async function fetchKeyVaultConfig( + options: KeyVaultOptions, +): Promise { + const { + vaultName, + secretName = DEFAULT_SECRET_NAME, + failOnError = false, + } = options; + + const fetcher = options.fetcher ?? makeAzureFetcher(options.credential); + + // Test-isolation guard: only block live fetches, not test-supplied + // fetcher functions. + if ( + options.fetcher === undefined && + inJest() && + !keyVaultAllowedInTests() + ) { + const msg = + "Refusing live Key Vault fetch from inside Jest. " + + "Set TYPEAGENT_ALLOW_KEYVAULT_IN_TESTS=1 to opt in, or " + + "inject a stub fetcher."; + if (failOnError) throw new Error(msg); + debug(msg); + return null; + } + + debug("fetching Key Vault secret"); + + if (!vaultName) { + const msg = "vaultName is required for Key Vault fetch"; + if (failOnError) throw new Error(msg); + debug(msg); + return null; + } + + let raw: string | null; + try { + raw = await fetcher(vaultName, secretName); + } catch (err) { + if (failOnError) throw err; + debug("fetch failed (continuing): %s", err); + return null; + } + + if (raw === null || raw.length === 0) { + debug("secret missing or empty"); + return null; + } + + if (Buffer.byteLength(raw, "utf8") > MAX_SECRET_BYTES) { + // The Azure SDK would have rejected the upload, but a + // hand-edited secret could in principle exceed this; surface + // a useful error rather than silently truncate. + const msg = + `Key Vault secret exceeds the ${MAX_SECRET_BYTES}-byte ` + + `limit. Split into multiple secrets or reduce content.`; + if (failOnError) throw new Error(msg); + debug(msg); + return null; + } + + let parsed: unknown; + try { + parsed = yaml.load(raw, { + filename: `keyvault://${vaultName}/${secretName}`, + }); + } catch (err) { + if (failOnError) throw err; + debug("parse failed (continuing): %s", err); + return null; + } + + if (parsed === null || parsed === undefined) { + return null; + } + if (typeof parsed !== "object" || Array.isArray(parsed)) { + const msg = + "Key Vault secret must contain a YAML mapping at the top level."; + if (failOnError) throw new Error(msg); + debug(msg); + return null; + } + + validateConfigTree(parsed, `keyvault://${vaultName}/${secretName}`); + return parsed as ConfigTree; +} diff --git a/ts/packages/config/src/loader.ts b/ts/packages/config/src/loader.ts new file mode 100644 index 0000000000..3e1412a815 --- /dev/null +++ b/ts/packages/config/src/loader.ts @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import yaml from "js-yaml"; +import dotenv from "dotenv"; +import registerDebug from "debug"; + +import { flatten, mergeFlat } from "./flatten.js"; +import { fetchKeyVaultConfig } from "./keyVault.js"; +import { validateConfigTree } from "./schema.js"; +import { + ConfigSource, + ConfigTree, + FlatEnv, + LoadConfigOptions, + LoadConfigResult, + SourceMap, +} from "./types.js"; + +const debug = registerDebug("typeagent:config"); + +/** + * Locate the TypeAgent `ts/` workspace root by walking up from this + * source file. The package lives at + * `ts/packages/config/dist/loader.js` once built (or `src/loader.ts` + * when run from source under ts-node), so we walk up four levels and + * verify by looking for `pnpm-workspace.yaml`. + */ +function defaultWorkspaceRoot(): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + let candidate = here; + for (let i = 0; i < 8; i++) { + if ( + fs.existsSync(path.join(candidate, "pnpm-workspace.yaml")) && + fs.existsSync(path.join(candidate, "package.json")) + ) { + return candidate; + } + const parent = path.dirname(candidate); + if (parent === candidate) break; + candidate = parent; + } + // Fall back to the current working directory; callers can override + // via `LoadConfigOptions.workspaceRoot`. + return process.cwd(); +} + +function readYamlFile(filePath: string): ConfigTree | null { + if (!fs.existsSync(filePath)) { + return null; + } + const text = fs.readFileSync(filePath, "utf8"); + const data = yaml.load(text, { filename: filePath }); + if (data === null || data === undefined) { + return null; + } + if (typeof data !== "object" || Array.isArray(data)) { + throw new Error( + `Config file ${filePath} must contain a YAML mapping at the top level.`, + ); + } + validateConfigTree(data, filePath); + return data as ConfigTree; +} + +function readDotEnvFile(filePath: string): FlatEnv { + if (!fs.existsSync(filePath)) { + return {}; + } + const text = fs.readFileSync(filePath, "utf8"); + const parsed = dotenv.parse(text); + return parsed; +} + +/** + * Synchronous loader used by tests and any entry point that cannot + * await. Reads YAML files and the `.env` fallback only — never reaches + * out to Key Vault. + * + * Precedence (low → high): `.env` → defaults → local → caller-provided + * `process.env`-style overrides via the `populateProcessEnv` flag. + */ +export function loadConfigSync( + options: LoadConfigOptions = {}, +): LoadConfigResult { + const root = options.workspaceRoot ?? defaultWorkspaceRoot(); + const defaultsPath = + options.defaultsPath ?? path.join(root, "config.defaults.yaml"); + const localPath = options.localPath ?? path.join(root, "config.local.yaml"); + const dotEnvPath = options.dotEnvPath ?? path.join(root, ".env"); + const trackSources = options.trackSources ?? false; + const populateProcessEnv = options.populateProcessEnv ?? true; + const strict = options.strict ?? true; + + debug( + "loading config (workspaceRoot=%s, defaults=%s, local=%s, dotenv=%s)", + root, + defaultsPath, + localPath, + dotEnvPath, + ); + + const layers: { source: ConfigSource; flat: FlatEnv }[] = []; + + // .env (legacy fallback, lowest precedence) + try { + const envFlat = readDotEnvFile(dotEnvPath); + if (Object.keys(envFlat).length > 0) { + layers.push({ source: ConfigSource.DotEnv, flat: envFlat }); + } + } catch (err) { + if (strict) throw err; + debug("error reading .env (continuing): %s", err); + } + + // config.defaults.yaml + try { + const tree = readYamlFile(defaultsPath); + if (tree) { + layers.push({ + source: ConfigSource.Defaults, + flat: flatten(tree), + }); + } + } catch (err) { + if (strict) throw err; + debug("error reading defaults (continuing): %s", err); + } + + // config.local.yaml + try { + const tree = readYamlFile(localPath); + if (tree) { + layers.push({ + source: ConfigSource.Local, + flat: flatten(tree), + }); + } + } catch (err) { + if (strict) throw err; + debug("error reading local (continuing): %s", err); + } + + // Merge in precedence order (later layers win). + const merged: FlatEnv = mergeFlat(...layers.map((l) => l.flat)); + + let sources: SourceMap | undefined; + if (trackSources) { + sources = {}; + for (const layer of layers) { + for (const k of Object.keys(layer.flat)) { + sources[k] = layer.source; + } + } + // Pre-existing process.env values are tracked but not + // overwritten — they sit at the top of the precedence chain + // when populateProcessEnv chooses not to clobber them. (See + // below.) + } + + if (populateProcessEnv) { + applyToProcessEnv(merged, sources); + } + + debug( + "loaded %d keys from %d layer(s)", + Object.keys(merged).length, + layers.length, + ); + + return sources !== undefined ? { env: merged, sources } : { env: merged }; +} + +/** + * Async loader. Performs the same work as `loadConfigSync` plus the + * optional Key Vault fetch when `options.keyVault` is supplied. + * + * Existing entry points should `await loadConfig()` once at startup, + * before any code that reads `process.env`. + * + * Precedence (low → high): `.env` → defaults → Key Vault → local → + * pre-existing `process.env`. + */ +export async function loadConfig( + options: LoadConfigOptions = {}, +): Promise { + if (!options.keyVault) { + return loadConfigSync(options); + } + + // Fetch the Key Vault layer first, then run the sync loader with + // the rest of the precedence chain. We splice the KV layer in at + // the correct position by routing through a custom assembly path. + const root = options.workspaceRoot ?? defaultWorkspaceRoot(); + const defaultsPath = + options.defaultsPath ?? path.join(root, "config.defaults.yaml"); + const localPath = options.localPath ?? path.join(root, "config.local.yaml"); + const dotEnvPath = options.dotEnvPath ?? path.join(root, ".env"); + const trackSources = options.trackSources ?? false; + const populateProcessEnv = options.populateProcessEnv ?? true; + const strict = options.strict ?? true; + + // Resolve vault name. If the caller didn't supply one, do a quick + // sync pre-pass of defaults+local to discover TYPEAGENT_SHAREDVAULT + // (the flat form of `vault.shared`). + let vaultName = options.keyVault.vaultName; + if (!vaultName) { + const preLayers: { source: ConfigSource; flat: FlatEnv }[] = []; + pushYamlLayer(preLayers, ConfigSource.Defaults, defaultsPath, false); + pushYamlLayer(preLayers, ConfigSource.Local, localPath, false); + pushFileLayer( + preLayers, + ConfigSource.DotEnv, + () => readDotEnvFile(dotEnvPath), + false, + ".env", + ); + const preMerged = mergeFlat(...preLayers.map((l) => l.flat)); + vaultName = preMerged.TYPEAGENT_SHAREDVAULT; + if (!vaultName) { + debug( + "key vault requested but no vault name supplied and " + + "vault.shared not found in defaults/local — skipping KV layer", + ); + return loadConfigSync(options); + } + debug("auto-discovered vault name: %s", vaultName); + } + + debug( + "loading config with key vault (workspaceRoot=%s, vault=%s)", + root, + vaultName, + ); + + const layers: { source: ConfigSource; flat: FlatEnv }[] = []; + + // .env + pushFileLayer( + layers, + ConfigSource.DotEnv, + () => readDotEnvFile(dotEnvPath), + strict, + ".env", + ); + + // defaults + pushYamlLayer(layers, ConfigSource.Defaults, defaultsPath, strict); + + // Key Vault (between defaults and local, per the locked design) + try { + const tree = await fetchKeyVaultConfig({ + ...options.keyVault, + vaultName, + failOnError: options.keyVault.failOnError ?? strict, + }); + if (tree) { + layers.push({ + source: ConfigSource.KeyVault, + flat: flatten(tree), + }); + } + } catch (err) { + if (strict) throw err; + debug("key vault layer skipped: %s", err); + } + + // local + pushYamlLayer(layers, ConfigSource.Local, localPath, strict); + + return finalize(layers, populateProcessEnv, trackSources); +} + +/** + * Push a flat env layer (read from a `.env` file) into the layers + * array, honoring `strict` mode for error handling. Helper extracted + * so `loadConfig` and `loadConfigSync` share identical semantics. + */ +function pushFileLayer( + layers: { source: ConfigSource; flat: FlatEnv }[], + source: ConfigSource, + read: () => FlatEnv, + strict: boolean, + label: string, +): void { + try { + const flat = read(); + if (Object.keys(flat).length > 0) { + layers.push({ source, flat }); + } + } catch (err) { + if (strict) throw err; + debug("error reading %s (continuing): %s", label, err); + } +} + +/** + * Push a YAML layer into the layers array, honoring `strict` mode. + */ +function pushYamlLayer( + layers: { source: ConfigSource; flat: FlatEnv }[], + source: ConfigSource, + filePath: string, + strict: boolean, +): void { + try { + const tree = readYamlFile(filePath); + if (tree) { + layers.push({ source, flat: flatten(tree) }); + } + } catch (err) { + if (strict) throw err; + debug("error reading %s (continuing): %s", filePath, err); + } +} + +/** + * Merge layers in precedence order, optionally produce a source map, + * and (optionally) push the result into `process.env`. + */ +function finalize( + layers: { source: ConfigSource; flat: FlatEnv }[], + populateProcessEnv: boolean, + trackSources: boolean, +): LoadConfigResult { + const merged: FlatEnv = mergeFlat(...layers.map((l) => l.flat)); + + let sources: SourceMap | undefined; + if (trackSources) { + sources = {}; + for (const layer of layers) { + for (const k of Object.keys(layer.flat)) { + sources[k] = layer.source; + } + } + } + + if (populateProcessEnv) { + applyToProcessEnv(merged, sources); + } + + debug( + "loaded %d keys from %d layer(s)", + Object.keys(merged).length, + layers.length, + ); + + return sources !== undefined ? { env: merged, sources } : { env: merged }; +} + +/** + * Merge a flat env map into `process.env`. Existing values in + * `process.env` are preserved — they sit at higher precedence than + * any config file (matching how `dotenv.config()` behaved). This + * lets developers override individual keys ad-hoc from the shell + * without editing YAML. + */ +function applyToProcessEnv(merged: FlatEnv, sources?: SourceMap): void { + for (const [key, value] of Object.entries(merged)) { + if (process.env[key] === undefined) { + process.env[key] = value; + } else if (sources) { + // Caller wants source tracking — record that the live + // process env "won" against the config file value. + sources[key] = ConfigSource.ProcessEnv; + } + } +} diff --git a/ts/packages/config/src/redact.ts b/ts/packages/config/src/redact.ts new file mode 100644 index 0000000000..bd0bb76b06 --- /dev/null +++ b/ts/packages/config/src/redact.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ConfigTree, FlatEnv } from "./types.js"; + +/** + * Default pattern matching keys whose values are sensitive. Matches + * the convention used elsewhere in the codebase (look for `key`, + * `secret`, `password`, `token`, `credential` substrings — case + * insensitive). The pattern is exported so callers can extend it. + */ +export const SECRET_KEY_PATTERN = /key|secret|password|token|credential/i; + +/** Sentinel used in place of redacted values. */ +export const REDACTED = ""; + +/** + * The string value of an Azure managed-identity setting in TypeAgent; + * always safe to display in plaintext. + */ +const NON_SECRET_VALUES = new Set(["", "identity"]); + +/** + * Should the value at `keyPath` be redacted? `keyPath` is expected to + * be a dotted path like `azure.openai.api_key`, or a flat env name + * like `AZURE_OPENAI_API_KEY`. + */ +export function shouldRedact(keyPath: string, value: unknown): boolean { + if (typeof value !== "string") return false; + if (NON_SECRET_VALUES.has(value)) return false; + return SECRET_KEY_PATTERN.test(keyPath); +} + +/** + * Return a copy of `tree` with values at sensitive keys replaced by + * ``. Non-string scalars are left as-is (booleans / + * numbers cannot leak credentials in any meaningful way). + */ +export function redactTree(tree: ConfigTree): ConfigTree { + return walk(tree, []) as ConfigTree; +} + +function walk(node: unknown, path: string[]): unknown { + if (node === null || node === undefined) return node; + if (typeof node !== "object") { + return shouldRedact(path.join("."), node) ? REDACTED : node; + } + if (Array.isArray(node)) { + return node.map((item, i) => walk(item, [...path, String(i)])); + } + const out: Record = {}; + for (const [k, v] of Object.entries(node as Record)) { + out[k] = walk(v, [...path, k]); + } + return out; +} + +/** + * Return a copy of `flat` with values at sensitive keys replaced by + * ``. Used by `typeagent-config show` when printing a + * merged flat env map. + */ +export function redactFlat(flat: FlatEnv): FlatEnv { + const out: FlatEnv = {}; + for (const [k, v] of Object.entries(flat)) { + out[k] = shouldRedact(k, v) ? REDACTED : v; + } + return out; +} diff --git a/ts/packages/config/src/runtime/build.ts b/ts/packages/config/src/runtime/build.ts new file mode 100644 index 0000000000..a08eb00d7b --- /dev/null +++ b/ts/packages/config/src/runtime/build.ts @@ -0,0 +1,801 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Build a typed `Config` from a flat env-var map (`FlatEnv`). + * + * The flat map is the lingua franca produced by either: + * - flattening structured YAML (`flatten()` on a `ConfigTree`), or + * - parsing a legacy `.env` file (`parseDotEnvFile`). + * + * The builder pattern-recognizes the long-standing + * `AZURE_OPENAI___[_PTU]` env-var convention + * and lifts it into the typed `Deployment` / `DeploymentEndpoint` + * structure. Anything it doesn't recognize lands in `Config.extra` + * verbatim, so unmigrated consumers (and the compatibility shim) can + * still find their values. + * + * The builder is the dual of `populateProcessEnv` in the same package: + * any `Config` round-trips through `populateProcessEnv → buildConfig` + * losslessly for typed sections, and bit-identically for `extra`. + */ + +import type { FlatEnv } from "../types.js"; +import { + isRegion, + regionFromEnvSuffix, + regionFromUrl, + type Region, +} from "./regions.js"; +import { + AuthMode, + authModeFromString, + Config, + Deployment, + DeploymentEndpoint, + DeploymentMode, + type AzureOpenAIConfig, +} from "./types.js"; + +/** + * Result of analyzing an `AZURE_OPENAI_(API_KEY|ENDPOINT)_` key. + * `suffix` is the raw env-var suffix (e.g. `GPT_4_O_EASTUS_PTU`); + * `deployment` is the lowercase deployment name extracted from the + * leading tokens; `region` and `mode` are pulled from the trailing + * tokens when recognizable. + */ +interface SuffixParse { + readonly suffix: string; + readonly deployment: string; + readonly region?: Region | undefined; + readonly mode: DeploymentMode; +} + +function parseSuffix(suffix: string): SuffixParse { + let tokens = suffix.split("_").filter((t) => t.length > 0); + let mode: DeploymentMode = "PAYG"; + + // Trailing _PTU marks a provisioned-throughput variant. + if ( + tokens.length > 0 && + tokens[tokens.length - 1].toUpperCase() === "PTU" + ) { + mode = "PTU"; + tokens = tokens.slice(0, -1); + } + + // Try to peel off a trailing region. Azure regions are 1-3 underscore- + // joined tokens (e.g. CANADACENTRAL, NORTH_CENTRAL_US is theoretical; + // actual region tokens like `northcentralus` are written contiguous). + // We try 1, 2, 3 trailing tokens and accept the longest match. + let region: Region | undefined; + for (let take = 3; take >= 1; take--) { + if (tokens.length < take) continue; + const candidate = tokens.slice(-take).join("").toLowerCase(); + const r = regionFromEnvSuffix(candidate); + if (r !== undefined) { + region = r; + tokens = tokens.slice(0, -take); + break; + } + } + + // Whatever remains is the deployment name (lowercase-snake). + const deployment = tokens.join("_").toLowerCase(); + return { suffix, deployment, region, mode }; +} + +/** Default priority by mode: PTU prefers tier 1, PAYG falls to tier 2. */ +function defaultPriority(mode: DeploymentMode): number { + return mode === "PTU" ? 1 : 2; +} + +function makeEndpoint( + endpoint: string, + region: Region, + mode: DeploymentMode, + auth: AuthMode, + capacity?: number, + priority?: number, + tpm?: number, +): DeploymentEndpoint { + const ep: DeploymentEndpoint = { + endpoint, + auth, + region, + mode, + priority: priority ?? defaultPriority(mode), + ...(capacity !== undefined ? { capacity } : {}), + ...(tpm !== undefined ? { tpm } : {}), + }; + return ep; +} + +function popString(flat: Map, key: string): string | undefined { + const v = flat.get(key); + if (v !== undefined) flat.delete(key); + return v; +} + +function popInt( + flat: Map, + key: string, + fallback?: number, +): number | undefined { + const raw = popString(flat, key); + if (raw === undefined) return fallback; + const n = parseInt(raw, 10); + if (!Number.isFinite(n)) return fallback; + return n; +} + +function popBool(flat: Map, key: string): boolean { + const raw = popString(flat, key); + if (raw === undefined) return false; + // Long-standing project convention: `1` / `true` are truthy. + return raw === "1" || raw.toLowerCase() === "true"; +} + +/** + * Build the typed `Config` from a flat env map. The input map is not + * mutated; the builder works on a private copy so it can pop keys as + * it consumes them and route the leftovers into `Config.extra`. + */ +export function buildConfig(flat: FlatEnv): Config { + const remaining = new Map(Object.entries(flat)); + + const azureOpenAI = buildAzureOpenAI(remaining); + const openAI = buildOpenAI(remaining); + const speech = buildSpeech(remaining); + const maps = buildMaps(remaining); + const msGraph = buildMsGraph(remaining); + const googleCalendar = buildGoogleCalendar(remaining); + const spotify = buildSpotify(remaining); + const wikipedia = buildWikipedia(remaining); + const storage = buildStorage(remaining); + const vault = buildVault(remaining); + const azureFoundry = buildAzureFoundry(remaining); + const reasoning = buildReasoning(remaining); + + return { + azureOpenAI, + ...(openAI ? { openAI } : {}), + speech, + maps, + msGraph, + googleCalendar, + spotify, + wikipedia, + storage, + vault, + ...(azureFoundry ? { azureFoundry } : {}), + ...(reasoning ? { reasoning } : {}), + extra: new Map(remaining), + }; +} + +// ---------------------------------------------------------------------------- +// Azure OpenAI +// ---------------------------------------------------------------------------- + +function buildAzureOpenAI(flat: Map): AzureOpenAIConfig { + const defaultAuthRaw = popString(flat, "AZURE_OPENAI_API_KEY"); + const defaultAuth = authModeFromString(defaultAuthRaw); + + const maxConcurrency = popInt(flat, "AZURE_OPENAI_MAX_CONCURRENCY", 4)!; + const maxTimeoutMs = popInt(flat, "AZURE_OPENAI_MAX_TIMEOUT", 60_000)!; + const maxRetryAttempts = popInt(flat, "AZURE_OPENAI_MAX_RETRYATTEMPTS", 3)!; + const responseFormat = popBool(flat, "AZURE_OPENAI_RESPONSE_FORMAT"); + const enableModelRequestLogging = popBool( + flat, + "ENABLE_MODEL_REQUEST_LOGGING", + ); + const maxPromptChars = popInt(flat, "AZURE_OPENAI_MAX_CHARS"); + + // Bare default chat endpoint (legacy AZURE_OPENAI_ENDPOINT). + const defaultChatEndpoint = popString(flat, "AZURE_OPENAI_ENDPOINT"); + const defaultChat = defaultChatEndpoint + ? makeBareEndpoint(defaultChatEndpoint, defaultAuth) + : undefined; + + // Service defaults: peel off bare embedding / image / video endpoints + // before the deployment-suffix scan so they don't get mistaken for + // deployment entries. + const defaultEmbedding = popServiceDefault( + flat, + ["EMBEDDING"], + defaultAuth, + ); + const defaultImage = popServiceDefault( + flat, + ["GPT_IMAGE_1_5"], + defaultAuth, + ); + const defaultVideo = popServiceDefault(flat, ["SORA_2"], defaultAuth); + + // Everything else with the AZURE_OPENAI_(ENDPOINT|API_KEY)_ + // shape is a deployment endpoint. Group by deployment name. + const deployments = collectDeployments(flat, defaultAuth); + + // Synthesize service defaults from the deployment list when not + // explicitly set. Legacy aiclient consumers (`createChatModelDefault`, + // bare `AZURE_OPENAI_ENDPOINT` lookups, etc.) need a fallback URL + // when no model name is specified. We pick the first endpoint of a + // conventional deployment in priority order. + const synthDefault = (names: string[]): DeploymentEndpoint | undefined => { + for (const n of names) { + const dep = deployments.get(n); + if (dep && dep.endpoints.length > 0) return dep.endpoints[0]; + } + return undefined; + }; + // For chat we additionally fall back to *any* configured deployment if + // none of the conventional names match — so a YAML that only defines + // exotic deployment names still yields a usable bare default endpoint. + const synthAnyChat = (): DeploymentEndpoint | undefined => { + for (const dep of deployments.values()) { + if (dep.endpoints.length > 0) return dep.endpoints[0]; + } + return undefined; + }; + const finalDefaultChat = + defaultChat ?? + synthDefault(["gpt_4_o", "gpt_4_1", "gpt_5"]) ?? + synthAnyChat(); + const finalDefaultEmbedding = + defaultEmbedding ?? synthDefault(["embedding", "embedding_3_large"]); + const finalDefaultImage = defaultImage ?? synthDefault(["gpt_image_1_5"]); + const finalDefaultVideo = defaultVideo ?? synthDefault(["sora_2"]); + + return { + defaultAuth, + maxConcurrency, + maxTimeoutMs, + maxRetryAttempts, + responseFormat, + enableModelRequestLogging, + ...(maxPromptChars !== undefined ? { maxPromptChars } : {}), + ...(finalDefaultChat ? { defaultChat: finalDefaultChat } : {}), + ...(finalDefaultEmbedding + ? { defaultEmbedding: finalDefaultEmbedding } + : {}), + ...(finalDefaultImage ? { defaultImage: finalDefaultImage } : {}), + ...(finalDefaultVideo ? { defaultVideo: finalDefaultVideo } : {}), + deployments, + }; +} + +function makeBareEndpoint( + endpoint: string, + auth: AuthMode, +): DeploymentEndpoint { + // Bare endpoints have no known region; we surface them with a + // sentinel region of "eastus" only so the type stays well-formed. + // Consumers should never read `.region` off a bare endpoint they + // got from `defaultChat`/`defaultEmbedding`/etc. — those exist to + // model the legacy "AZURE_OPENAI_ENDPOINT" case where region is + // unknowable from env vars alone. + return { + endpoint, + auth, + region: "eastus", + mode: "PAYG", + priority: 1, + }; +} + +/** + * Pop a service-default (`EMBEDDING`, `GPT_IMAGE_1_5`, `SORA_2`) bare + * endpoint+key pair from `flat`. Tries each candidate name in order + * (the first one with an endpoint set wins). + */ +function popServiceDefault( + flat: Map, + candidateNames: readonly string[], + inheritedAuth: AuthMode, +): DeploymentEndpoint | undefined { + for (const name of candidateNames) { + const endpoint = popString(flat, `AZURE_OPENAI_ENDPOINT_${name}`); + const keyRaw = popString(flat, `AZURE_OPENAI_API_KEY_${name}`); + if (endpoint !== undefined) { + const auth = + keyRaw !== undefined + ? authModeFromString(keyRaw) + : inheritedAuth; + return makeBareEndpoint(endpoint, auth); + } + } + return undefined; +} + +interface PartialEndpoint { + endpoint?: string; + auth?: AuthMode; + region?: Region; + mode: DeploymentMode; + capacity?: number; + priority?: number; + tpm?: number; +} + +/** Per-suffix overrides parsed from `AZURE_OPENAI_POOL_` JSON. */ +interface PoolOverride { + suffix: string; + region?: Region; + mode?: DeploymentMode; + capacity?: number; + priority?: number; + tpm?: number; +} + +/** + * Parse a legacy `AZURE_OPENAI_POOL_*` value, which is a near-JSON + * array using bare-word keys like `[{suffix:GPT_4_O_EASTUS,...}]`. + * Returns the parsed entries, or `undefined` if the value is missing + * or unparseable. + */ +function parsePoolOverride( + raw: string | undefined, +): PoolOverride[] | undefined { + if (raw === undefined) return undefined; + // The legacy format is near-JSON with bare-word keys *and* bare-word + // string values, e.g. `[{suffix:GPT_4_O_EASTUS,region:eastus,mode:PAYG}]`. + // Quote both keys (preceded by `{` or `,`) and bare-word values + // (followed by `,`, `}`, or `]`) to make it valid JSON. Numbers and + // already-quoted strings pass through untouched. + const json = raw + .replace(/([{,])\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":') + .replace(/:\s*([A-Za-z_][A-Za-z0-9_]*)\s*([,}\]])/g, ':"$1"$2'); + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return undefined; + } + if (!Array.isArray(parsed)) return undefined; + const out: PoolOverride[] = []; + for (const e of parsed) { + if ( + e && + typeof e === "object" && + typeof (e as any).suffix === "string" + ) { + const o = e as Record; + const ov: PoolOverride = { suffix: o.suffix as string }; + if (typeof o.region === "string" && isRegion(o.region)) + ov.region = o.region as Region; + if (o.mode === "PTU" || o.mode === "PAYG") ov.mode = o.mode; + if (typeof o.capacity === "number") ov.capacity = o.capacity; + if (typeof o.priority === "number") ov.priority = o.priority; + if (typeof o.tpm === "number") ov.tpm = o.tpm; + out.push(ov); + } + } + return out; +} + +function collectDeployments( + flat: Map, + defaultAuth: AuthMode, +): Map { + // Group endpoint + key parses by (deployment, suffix). + // The suffix is the unique runtime identity of an endpoint (bakes + // region + PTU into one string), letting us match endpoint and key + // lines that came in with different casing or ordering. + interface PartialBySuffix { + deployment: string; + bySuffix: Map; + } + const partials = new Map(); + + function ensure(deployment: string): Map { + let p = partials.get(deployment); + if (!p) { + p = { deployment, bySuffix: new Map() }; + partials.set(deployment, p); + } + return p.bySuffix; + } + + // Snapshot keys before iterating; we mutate `flat` while walking. + // Track which raw keys contributed to each (deployment, suffix) + // partial so we can restore them to `flat` if the partial fails to + // materialize (e.g. no derivable region). + const partialKeys = new Map(); + const keys = [...flat.keys()]; + for (const key of keys) { + const endpointMatch = /^AZURE_OPENAI_ENDPOINT_(.+)$/.exec(key); + const keyMatch = /^AZURE_OPENAI_API_KEY_(.+)$/.exec(key); + if (!endpointMatch && !keyMatch) continue; + const suffix = (endpointMatch ?? keyMatch)![1]; + const value = flat.get(key)!; + + const parse = parseSuffix(suffix); + if (parse.deployment.length === 0) continue; + flat.delete(key); + const bySuffix = ensure(parse.deployment); + let partial = bySuffix.get(parse.suffix); + if (!partial) { + partial = { mode: parse.mode }; + if (parse.region !== undefined) partial.region = parse.region; + bySuffix.set(parse.suffix, partial); + partialKeys.set(partial, []); + } + partialKeys.get(partial)!.push(key); + if (endpointMatch) { + partial.endpoint = value; + } else { + partial.auth = authModeFromString(value); + } + } + + // Apply AZURE_OPENAI_POOL_ overrides where present. + // The override list keys on the legacy suffix string and carries + // sku / capacity / priority / tpm that the deployment-suffix env + // vars cannot express on their own. Consumed (deleted) so they + // don't survive into Config.extra. + for (const [name, group] of partials) { + const poolKey = `AZURE_OPENAI_POOL_${name.toUpperCase()}`; + const overrides = parsePoolOverride(flat.get(poolKey)); + if (overrides !== undefined) { + flat.delete(poolKey); + for (const ov of overrides) { + const partial = group.bySuffix.get(ov.suffix); + if (!partial) continue; + if (ov.mode !== undefined) partial.mode = ov.mode; + if (ov.region !== undefined && partial.region === undefined) + partial.region = ov.region; + if (ov.capacity !== undefined) partial.capacity = ov.capacity; + if (ov.priority !== undefined) partial.priority = ov.priority; + if (ov.tpm !== undefined) partial.tpm = ov.tpm; + } + } + } + + // Materialize: only entries with an endpoint become DeploymentEndpoints; + // entries with only a key are dropped (and would already be in `extra` + // if we hadn't deleted them — which we did, so this is data loss for + // pathological inputs. That's acceptable: a key without an endpoint is + // not a valid deployment in any case.) + const result = new Map(); + for (const [name, group] of partials) { + const list: DeploymentEndpoint[] = []; + for (const partial of group.bySuffix.values()) { + if (!partial.endpoint) continue; + // Last-ditch region derivation from URL host. + const region = partial.region ?? regionFromUrl(partial.endpoint); + if (!region) { + // Restore raw keys so the value isn't lost. + const orig = partialKeys.get(partial) ?? []; + for (const k of orig) { + if (!flat.has(k)) { + // Reconstruct value from partial: this branch + // only runs for unmaterializable partials, so + // we know endpoint/auth came from the raw flat + // map. Look up via the raw key prefix. + if (k.startsWith("AZURE_OPENAI_ENDPOINT_")) { + if (partial.endpoint) flat.set(k, partial.endpoint); + } else if (k.startsWith("AZURE_OPENAI_API_KEY_")) { + if (partial.auth) + flat.set( + k, + partial.auth.kind === "identity" + ? "identity" + : partial.auth.value, + ); + } + } + } + continue; + } + const auth = partial.auth ?? defaultAuth; + list.push( + makeEndpoint( + partial.endpoint, + region, + partial.mode, + auth, + partial.capacity, + partial.priority, + partial.tpm, + ), + ); + } + if (list.length === 0) continue; + list.sort((a, b) => a.priority - b.priority); + result.set(name, { name, endpoints: list }); + } + return result; +} + +// ---------------------------------------------------------------------------- +// Speech / Maps / Identity / Database / Storage +// ---------------------------------------------------------------------------- + +function buildOpenAI(flat: Map) { + const main = buildOpenAIVariant(flat, ""); + const local = buildOpenAIVariant(flat, "_LOCAL"); + if (!main && !local) return undefined; + if (!main) { + // Only a local variant configured. Synthesize a stub main with + // an empty apiKey so consumers can still discriminate; the + // typed `openAI` slot's main fields are largely unused when + // only `local` is set. + return { ...emptyOpenAI(), local }; + } + return local ? { ...main, local } : main; +} + +function emptyOpenAI() { + return { + apiKey: "", + responseFormat: false, + maxConcurrency: 4, + maxTimeoutMs: 60_000, + maxRetryAttempts: 3, + }; +} + +function buildOpenAIVariant(flat: Map, suffix: string) { + const apiKey = popString(flat, `OPENAI_API_KEY${suffix}`); + const endpoint = popString(flat, `OPENAI_ENDPOINT${suffix}`); + const endpointEmbedding = popString( + flat, + `OPENAI_ENDPOINT_EMBEDDING${suffix}`, + ); + const model = popString(flat, `OPENAI_MODEL${suffix}`); + const modelEmbedding = popString(flat, `OPENAI_MODEL_EMBEDDING${suffix}`); + const organization = popString(flat, `OPENAI_ORGANIZATION${suffix}`); + const responseFormat = popBool(flat, `OPENAI_RESPONSE_FORMAT${suffix}`); + const maxConcurrency = popInt(flat, `OPENAI_MAX_CONCURRENCY${suffix}`, 4)!; + const maxTimeoutMs = popInt(flat, `OPENAI_MAX_TIMEOUT${suffix}`, 60_000)!; + const maxRetryAttempts = popInt( + flat, + `OPENAI_MAX_RETRYATTEMPTS${suffix}`, + 3, + )!; + + if (apiKey === undefined) { + // Restore any popped values so they survive into `extra` — + // an OpenAI variant without an API key is meaningless. + if (endpoint !== undefined) + flat.set(`OPENAI_ENDPOINT${suffix}`, endpoint); + if (endpointEmbedding !== undefined) + flat.set(`OPENAI_ENDPOINT_EMBEDDING${suffix}`, endpointEmbedding); + if (model !== undefined) flat.set(`OPENAI_MODEL${suffix}`, model); + if (modelEmbedding !== undefined) + flat.set(`OPENAI_MODEL_EMBEDDING${suffix}`, modelEmbedding); + if (organization !== undefined) + flat.set(`OPENAI_ORGANIZATION${suffix}`, organization); + return undefined; + } + + return { + apiKey, + ...(endpoint !== undefined ? { endpoint } : {}), + ...(endpointEmbedding !== undefined ? { endpointEmbedding } : {}), + ...(model !== undefined ? { model } : {}), + ...(modelEmbedding !== undefined ? { modelEmbedding } : {}), + ...(organization !== undefined ? { organization } : {}), + responseFormat, + maxConcurrency, + maxTimeoutMs, + maxRetryAttempts, + }; +} + +function buildSpeech(flat: Map) { + const keyRaw = popString(flat, "SPEECH_SDK_KEY"); + const region = popString(flat, "SPEECH_SDK_REGION"); + const endpoint = popString(flat, "SPEECH_SDK_ENDPOINT"); + if (!region) return undefined; + if (!isRegion(region)) { + // Unknown region — leave the keys in `extra` rather than + // producing a malformed typed object. + if (keyRaw !== undefined) flat.set("SPEECH_SDK_KEY", keyRaw); + flat.set("SPEECH_SDK_REGION", region); + if (endpoint !== undefined) flat.set("SPEECH_SDK_ENDPOINT", endpoint); + return undefined; + } + return { + auth: authModeFromString(keyRaw), + region, + ...(endpoint !== undefined ? { endpoint } : {}), + }; +} + +function buildMaps(flat: Map) { + const clientId = popString(flat, "AZURE_MAPS_CLIENTID"); + const endpoint = popString(flat, "AZURE_MAPS_ENDPOINT"); + if (!clientId || !endpoint) { + if (clientId !== undefined) flat.set("AZURE_MAPS_CLIENTID", clientId); + if (endpoint !== undefined) flat.set("AZURE_MAPS_ENDPOINT", endpoint); + return undefined; + } + return { clientId, endpoint }; +} + +function buildMsGraph(flat: Map) { + const clientId = popString(flat, "MSGRAPH_APP_CLIENTID"); + const clientSecret = popString(flat, "MSGRAPH_APP_CLIENTSECRET"); + const tenantId = popString(flat, "MSGRAPH_APP_TENANTID"); + const username = popString(flat, "MSGRAPH_APP_USERNAME"); + const password = popString(flat, "MSGRAPH_APP_PASSWD"); + if (!clientId && !clientSecret && !tenantId) return undefined; + return { + clientId: clientId ?? "", + clientSecret: clientSecret ?? "", + tenantId: tenantId ?? "", + ...(username !== undefined ? { username } : {}), + ...(password !== undefined ? { password } : {}), + }; +} + +function buildGoogleCalendar(flat: Map) { + const clientId = popString(flat, "GOOGLE_CALENDAR_CLIENT_ID"); + const clientSecret = popString(flat, "GOOGLE_CALENDAR_CLIENT_SECRET"); + if (!clientId || !clientSecret) return undefined; + return { clientId, clientSecret }; +} + +function buildSpotify(flat: Map) { + const clientId = popString(flat, "SPOTIFY_APP_CLI"); + const clientSecret = popString(flat, "SPOTIFY_APP_CLISEC"); + const portStr = popString(flat, "SPOTIFY_APP_PORT"); + if (!clientId || !clientSecret) return undefined; + const port = portStr ? parseInt(portStr, 10) : 9999; + return { + clientId, + clientSecret, + port: Number.isFinite(port) ? port : 9999, + }; +} + +function buildWikipedia(flat: Map) { + const clientId = popString(flat, "WIKIPEDIA_CLIENT_ID"); + const clientSecret = popString(flat, "WIKIPEDIA_CLIENT_SECRET"); + const endpoint = popString(flat, "WIKIPEDIA_ENDPOINT"); + if (!clientId && !clientSecret && !endpoint) return undefined; + return { + clientId: clientId ?? "", + clientSecret: clientSecret ?? "", + endpoint: endpoint ?? "", + }; +} + +function buildStorage(flat: Map) { + const azureAccount = popString(flat, "AZURE_STORAGE_ACCOUNT"); + const azureContainer = popString(flat, "AZURE_STORAGE_CONTAINER"); + const cosmosDbConnectionString = popString( + flat, + "COSMOSDB_CONNECTION_STRING", + ); + const mongoDbConnectionString = popString( + flat, + "MONGODB_CONNECTION_STRING", + ); + const awsBucket = popString(flat, "AWS_S3_BUCKET_NAME"); + const awsRegion = popString(flat, "AWS_S3_REGION"); + const awsAccessKey = popString(flat, "AWS_ACCESS_KEY_ID"); + const awsSecret = popString(flat, "AWS_SECRET_ACCESS_KEY"); + + const azure = + azureAccount && azureContainer + ? { account: azureAccount, container: azureContainer } + : undefined; + const aws = + awsBucket && awsRegion && awsAccessKey && awsSecret + ? { + bucketName: awsBucket, + region: awsRegion, + accessKeyId: awsAccessKey, + secretAccessKey: awsSecret, + } + : undefined; + const database = + cosmosDbConnectionString || mongoDbConnectionString + ? { + ...(cosmosDbConnectionString !== undefined + ? { cosmosDbConnectionString } + : {}), + ...(mongoDbConnectionString !== undefined + ? { mongoDbConnectionString } + : {}), + } + : undefined; + + const elasticApiKey = popString(flat, "ELASTIC_API_KEY"); + const elasticUri = popString(flat, "ELASTIC_URI"); + const elastic = + elasticApiKey && elasticUri + ? { apiKey: elasticApiKey, uri: elasticUri } + : undefined; + + return { + ...(azure ? { azure } : {}), + ...(aws ? { aws } : {}), + ...(database ? { database } : {}), + ...(elastic ? { elastic } : {}), + }; +} + +function buildVault(flat: Map) { + const shared = popString(flat, "TYPEAGENT_SHAREDVAULT"); + if (!shared) return undefined; + return { shared }; +} + +function buildAzureFoundry(flat: Map) { + const bingEndpoint = popString(flat, "BING_WITH_GROUNDING_ENDPOINT"); + const bingAgentId = popString(flat, "BING_WITH_GROUNDING_AGENT_ID"); + const bingUrlResolutionAgentId = popString( + flat, + "BING_WITH_GROUNDING_URL_RESOLUTION_AGENT_ID", + ); + const bingUrlResolutionConnectionId = popString( + flat, + "BING_WITH_GROUNDING_URL_RESOLUTION_CONNECTION_ID", + ); + const validatorAgentId = popString( + flat, + "AZURE_FOUNDRY_AGENT_ID_VALIDATOR", + ); + const aliasKeywordExtractorAgentId = popString( + flat, + "AZURE_FOUNDRY_AGENT_ID_ALIAS_KEYWORD_EXTRACTOR", + ); + const openPhraseGeneratorAgentId = popString( + flat, + "AZURE_FOUNDRY_AGENT_ID_OPEN_PHRASE_GENERATOR", + ); + const httpEndpointLogicAppConnectionId = popString( + flat, + "LOGIC_APP_CONNECTION_ID_GET_HTTP_ENDPOINT", + ); + const any = + bingEndpoint ?? + bingAgentId ?? + bingUrlResolutionAgentId ?? + bingUrlResolutionConnectionId ?? + validatorAgentId ?? + aliasKeywordExtractorAgentId ?? + openPhraseGeneratorAgentId ?? + httpEndpointLogicAppConnectionId; + if (any === undefined) return undefined; + return { + ...(bingEndpoint !== undefined ? { bingEndpoint } : {}), + ...(bingAgentId !== undefined ? { bingAgentId } : {}), + ...(bingUrlResolutionAgentId !== undefined + ? { bingUrlResolutionAgentId } + : {}), + ...(bingUrlResolutionConnectionId !== undefined + ? { bingUrlResolutionConnectionId } + : {}), + ...(validatorAgentId !== undefined ? { validatorAgentId } : {}), + ...(aliasKeywordExtractorAgentId !== undefined + ? { aliasKeywordExtractorAgentId } + : {}), + ...(openPhraseGeneratorAgentId !== undefined + ? { openPhraseGeneratorAgentId } + : {}), + ...(httpEndpointLogicAppConnectionId !== undefined + ? { httpEndpointLogicAppConnectionId } + : {}), + }; +} + +function buildReasoning(flat: Map) { + const timeoutRaw = popString(flat, "TYPEAGENT_REASONING_TIMEOUT_MS"); + const copilotModel = popString(flat, "COPILOT_REASONING_MODEL"); + if (timeoutRaw === undefined && copilotModel === undefined) + return undefined; + const timeoutMs = + timeoutRaw !== undefined ? parseInt(timeoutRaw, 10) : undefined; + return { + ...(Number.isFinite(timeoutMs) ? { timeoutMs } : {}), + ...(copilotModel !== undefined ? { copilotModel } : {}), + }; +} + +// Re-exported helpers for tests / future builders. +export { parseSuffix }; diff --git a/ts/packages/config/src/runtime/load.ts b/ts/packages/config/src/runtime/load.ts new file mode 100644 index 0000000000..c18c5b7d00 --- /dev/null +++ b/ts/packages/config/src/runtime/load.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Orchestrator: load layered YAML/.env config, build the typed `Config` + * object, and (optionally) populate `process.env` for unmigrated + * consumers. + * + * The plumbing layers (`loadConfigSync`, `flatten`, etc.) are + * unchanged; this module is the new typed entry point that consumers + * should migrate toward. + */ + +import { loadConfigSync } from "../loader.js"; +import type { LoadConfigOptions } from "../types.js"; +import { buildConfig } from "./build.js"; +import { applyToProcessEnv } from "./shim.js"; +import type { Config } from "./types.js"; + +export interface LoadRuntimeConfigOptions extends LoadConfigOptions { + /** + * After building the typed `Config`, project it onto `process.env` + * so consumers that still call `process.env.AZURE_OPENAI_*` + * directly keep working. Defaults to true. + * + * Set to false in tests that want to verify "no env-var leakage". + */ + readonly populateProcessEnv?: boolean; +} + +export interface RuntimeConfigResult { + readonly config: Config; +} + +/** + * Synchronous typed-config load. Returns the typed `Config` and, by + * default, populates `process.env` with the legacy flat keys. + */ +export function loadRuntimeConfigSync( + options: LoadRuntimeConfigOptions = {}, +): RuntimeConfigResult { + // The underlying loader still does the file/Vault layering and + // returns a flat env map. We just hand that to the typed builder. + // Delegate `populateProcessEnv` to our own shim (we want to do it + // AFTER building, not before — so the typed Config is the source + // of truth, not whatever was already in process.env). + const result = loadConfigSync({ ...options, populateProcessEnv: false }); + const config = buildConfig(result.env); + if (options.populateProcessEnv ?? true) { + applyToProcessEnv(config); + } + return { config }; +} diff --git a/ts/packages/config/src/runtime/regions.ts b/ts/packages/config/src/runtime/regions.ts new file mode 100644 index 0000000000..c7759035d5 --- /dev/null +++ b/ts/packages/config/src/runtime/regions.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Canonical Azure region tokens recognized by the typed Config layer. + * + * The list is deliberately closed: a region not in this set is rejected + * at config-load time. Adding a new region means editing this file — + * which forces a code review and keeps YAML files comparable across + * developers. The escape hatch for one-off / preview regions is the + * `extra:` passthrough on `Config`. + */ +export const REGIONS = [ + "eastus", + "eastus2", + "westus", + "westus2", + "westus3", + "centralus", + "northcentralus", + "southcentralus", + "westcentralus", + "swedencentral", + "francecentral", + "germanywestcentral", + "norwayeast", + "northeurope", + "westeurope", + "uksouth", + "ukwest", + "switzerlandnorth", + "japaneast", + "japanwest", + "australiaeast", + "koreacentral", + "southeastasia", + "eastasia", + "centralindia", + "southindia", + "brazilsouth", + "canadacentral", + "canadaeast", +] as const; + +export type Region = (typeof REGIONS)[number]; + +const REGION_SET: ReadonlySet = new Set(REGIONS); + +export function isRegion(value: unknown): value is Region { + return typeof value === "string" && REGION_SET.has(value); +} + +/** + * Convert a YAML-cased region (e.g. `swedencentral`) into the uppercase + * env-var suffix used by the legacy `AZURE_OPENAI_*_SWEDENCENTRAL` + * convention. Used by the compatibility shim. + */ +export function regionToEnvSuffix(region: Region): string { + return region.toUpperCase(); +} + +/** + * Inverse of `regionToEnvSuffix`: parse a single env-var suffix token + * back into a canonical region (or `undefined` if it doesn't match). + * Used by the importer to recognize regional variants in flat .env files. + */ +export function regionFromEnvSuffix(suffix: string): Region | undefined { + const lower = suffix.toLowerCase(); + return REGION_SET.has(lower) ? (lower as Region) : undefined; +} + +/** + * Heuristically derive a region from an Azure OpenAI endpoint URL by + * scanning the hostname for any canonical region token. Works for the + * standard `*-openai-*.openai.azure.com` and + * `*.cognitiveservices.azure.com` host patterns. Returns `undefined` + * if no token matches — caller should require an explicit `region:`. + */ +/** + * Common short names that appear in Azure resource hostnames in + * place of the canonical region token (e.g. `*-openai-sweden*` for + * `swedencentral`). Used by `regionFromUrl` so that hosts like + * `octo-aisystems-openai-sweden.openai.azure.com` resolve cleanly + * without needing an explicit `region:` in the YAML. + */ +const URL_REGION_ALIASES: ReadonlyMap = new Map([ + ["sweden", "swedencentral"], + ["france", "francecentral"], + ["korea", "koreacentral"], + ["japan", "japaneast"], + ["uk", "uksouth"], + ["australia", "australiaeast"], + ["canada", "canadacentral"], + ["brazil", "brazilsouth"], + ["norway", "norwayeast"], + ["switzerland", "switzerlandnorth"], + ["germany", "germanywestcentral"], + ["india", "centralindia"], +]); + +export function regionFromUrl(url: string): Region | undefined { + let host: string; + try { + host = new URL(url).hostname.toLowerCase(); + } catch { + return undefined; + } + // Prefer the longest canonical match so e.g. "eastus2" wins over "eastus". + let best: Region | undefined; + for (const r of REGIONS) { + if (host.includes(r) && (!best || r.length > best.length)) { + best = r; + } + } + if (best) return best; + // Fall back to known short-form aliases. + for (const [alias, region] of URL_REGION_ALIASES) { + if (host.includes(alias)) return region; + } + return undefined; +} diff --git a/ts/packages/config/src/runtime/shim.ts b/ts/packages/config/src/runtime/shim.ts new file mode 100644 index 0000000000..2318fd2d22 --- /dev/null +++ b/ts/packages/config/src/runtime/shim.ts @@ -0,0 +1,317 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Compatibility shim: project a typed `Config` back onto the legacy + * `process.env`-style flat key/value map. + * + * Used at startup so that consumers which still call + * `getEnvSetting(env, "AZURE_OPENAI_ENDPOINT_GPT_4_O", suffix)` keep + * working unchanged while we migrate them one at a time. + * + * Round-trip property: for any `Config` produced by `buildConfig(flat)`, + * the result of `populateProcessEnv(config)` is a superset of the + * typed sections of `flat` plus the `extra` passthrough — no typed + * data is lost. (Some normalization happens: booleans become "1"/"0", + * regions become uppercase env-var suffixes, etc.) + */ + +import { regionToEnvSuffix } from "./regions.js"; +import type { + AuthMode, + Config, + Deployment, + DeploymentEndpoint, +} from "./types.js"; + +/** A flat env-var name → value map. Same shape as `FlatEnv`. */ +export type EnvOutput = Record; + +/** + * Convert an `AuthMode` into its env-var string representation. + * Identity becomes the literal `"identity"`; key auth becomes the + * raw key value. + */ +function authToString(auth: AuthMode): string { + return auth.kind === "identity" ? "identity" : auth.value; +} + +function emitEndpoint( + out: EnvOutput, + deploymentSuffix: string, + region: string, + endpoint: DeploymentEndpoint, +): void { + const suffix = `${deploymentSuffix}_${region}`; + out[`AZURE_OPENAI_ENDPOINT_${suffix}`] = endpoint.endpoint; + out[`AZURE_OPENAI_API_KEY_${suffix}`] = authToString(endpoint.auth); +} + +function emitDeployment(out: EnvOutput, deployment: Deployment): void { + const suffix = deployment.name.toUpperCase(); + const overrides: Array> = []; + for (const endpoint of deployment.endpoints) { + const regionSuffix = + endpoint.mode === "PTU" + ? `${regionToEnvSuffix(endpoint.region)}_PTU` + : regionToEnvSuffix(endpoint.region); + emitEndpoint(out, suffix, regionSuffix, endpoint); + // Capture capacity/priority/tpm into the legacy POOL override + // JSON so unmigrated consumers can still see them. + if ( + endpoint.capacity !== undefined || + endpoint.tpm !== undefined || + endpoint.priority !== (endpoint.mode === "PTU" ? 1 : 2) + ) { + const o: Record = { + suffix: `${suffix}_${regionSuffix}`, + region: endpoint.region, + mode: endpoint.mode, + }; + if (endpoint.capacity !== undefined) o.capacity = endpoint.capacity; + if (endpoint.tpm !== undefined) o.tpm = endpoint.tpm; + o.priority = endpoint.priority; + overrides.push(o); + } + } + if (overrides.length > 0) { + // Render with bare-word keys to match the legacy format. + const body = overrides + .map( + (o) => + "{" + + Object.entries(o) + .map(([k, v]) => + typeof v === "string" ? `${k}:${v}` : `${k}:${v}`, + ) + .join(",") + + "}", + ) + .join(","); + out[`AZURE_OPENAI_POOL_${suffix}`] = `[${body}]`; + } +} + +/** + * Build the flat env-var map. Pure function — does not touch + * `process.env`. Use `applyToProcessEnv` to actually mutate the + * global. + */ +export function configToEnv(config: Config): EnvOutput { + const out: EnvOutput = {}; + + // Azure OpenAI section. + const ao = config.azureOpenAI; + out.AZURE_OPENAI_API_KEY = authToString(ao.defaultAuth); + out.AZURE_OPENAI_MAX_CONCURRENCY = String(ao.maxConcurrency); + out.AZURE_OPENAI_MAX_TIMEOUT = String(ao.maxTimeoutMs); + out.AZURE_OPENAI_MAX_RETRYATTEMPTS = String(ao.maxRetryAttempts); + out.AZURE_OPENAI_RESPONSE_FORMAT = ao.responseFormat ? "1" : "0"; + if (ao.maxPromptChars !== undefined) { + out.AZURE_OPENAI_MAX_CHARS = String(ao.maxPromptChars); + } + if (ao.enableModelRequestLogging) { + out.ENABLE_MODEL_REQUEST_LOGGING = "true"; + } + + if (ao.defaultChat) { + out.AZURE_OPENAI_ENDPOINT = ao.defaultChat.endpoint; + } + if (ao.defaultEmbedding) { + out.AZURE_OPENAI_ENDPOINT_EMBEDDING = ao.defaultEmbedding.endpoint; + out.AZURE_OPENAI_API_KEY_EMBEDDING = authToString( + ao.defaultEmbedding.auth, + ); + } + if (ao.defaultImage) { + out.AZURE_OPENAI_ENDPOINT_GPT_IMAGE_1_5 = ao.defaultImage.endpoint; + out.AZURE_OPENAI_API_KEY_GPT_IMAGE_1_5 = authToString( + ao.defaultImage.auth, + ); + } + if (ao.defaultVideo) { + out.AZURE_OPENAI_ENDPOINT_SORA_2 = ao.defaultVideo.endpoint; + out.AZURE_OPENAI_API_KEY_SORA_2 = authToString(ao.defaultVideo.auth); + } + + for (const deployment of ao.deployments.values()) { + emitDeployment(out, deployment); + } + + // Speech. + if (config.speech) { + out.SPEECH_SDK_KEY = authToString(config.speech.auth); + out.SPEECH_SDK_REGION = config.speech.region; + if (config.speech.endpoint) { + out.SPEECH_SDK_ENDPOINT = config.speech.endpoint; + } + } + + // Maps. + if (config.maps) { + out.AZURE_MAPS_CLIENTID = config.maps.clientId; + out.AZURE_MAPS_ENDPOINT = config.maps.endpoint; + } + + // Microsoft Graph. + if (config.msGraph) { + out.MSGRAPH_APP_CLIENTID = config.msGraph.clientId; + out.MSGRAPH_APP_CLIENTSECRET = config.msGraph.clientSecret; + out.MSGRAPH_APP_TENANTID = config.msGraph.tenantId; + if (config.msGraph.username !== undefined) { + out.MSGRAPH_APP_USERNAME = config.msGraph.username; + } + if (config.msGraph.password !== undefined) { + out.MSGRAPH_APP_PASSWD = config.msGraph.password; + } + } + + // Google Calendar. + if (config.googleCalendar) { + out.GOOGLE_CALENDAR_CLIENT_ID = config.googleCalendar.clientId; + out.GOOGLE_CALENDAR_CLIENT_SECRET = config.googleCalendar.clientSecret; + } + + // Spotify. + if (config.spotify) { + out.SPOTIFY_APP_CLI = config.spotify.clientId; + out.SPOTIFY_APP_CLISEC = config.spotify.clientSecret; + out.SPOTIFY_APP_PORT = String(config.spotify.port); + } + + // Wikipedia. + if (config.wikipedia) { + if (config.wikipedia.clientId) { + out.WIKIPEDIA_CLIENT_ID = config.wikipedia.clientId; + } + if (config.wikipedia.clientSecret) { + out.WIKIPEDIA_CLIENT_SECRET = config.wikipedia.clientSecret; + } + if (config.wikipedia.endpoint) { + out.WIKIPEDIA_ENDPOINT = config.wikipedia.endpoint; + } + } + + // Storage. + if (config.storage.azure) { + out.AZURE_STORAGE_ACCOUNT = config.storage.azure.account; + out.AZURE_STORAGE_CONTAINER = config.storage.azure.container; + } + if (config.storage.aws) { + out.AWS_S3_BUCKET_NAME = config.storage.aws.bucketName; + out.AWS_S3_REGION = config.storage.aws.region; + out.AWS_ACCESS_KEY_ID = config.storage.aws.accessKeyId; + out.AWS_SECRET_ACCESS_KEY = config.storage.aws.secretAccessKey; + } + if (config.storage.database?.cosmosDbConnectionString) { + out.COSMOSDB_CONNECTION_STRING = + config.storage.database.cosmosDbConnectionString; + } + if (config.storage.database?.mongoDbConnectionString) { + out.MONGODB_CONNECTION_STRING = + config.storage.database.mongoDbConnectionString; + } + + // Vault. + if (config.vault?.shared) { + out.TYPEAGENT_SHAREDVAULT = config.vault.shared; + } + + // OpenAI (main + named variants like LOCAL). + if (config.openAI) { + emitOpenAIVariant(out, config.openAI, ""); + if (config.openAI.local) { + emitOpenAIVariant(out, config.openAI.local, "_LOCAL"); + } + } + + // Azure AI Foundry / Bing-with-Grounding / Logic-App. + if (config.azureFoundry) { + const f = config.azureFoundry; + if (f.bingEndpoint !== undefined) + out.BING_WITH_GROUNDING_ENDPOINT = f.bingEndpoint; + if (f.bingAgentId !== undefined) + out.BING_WITH_GROUNDING_AGENT_ID = f.bingAgentId; + if (f.bingUrlResolutionAgentId !== undefined) + out.BING_WITH_GROUNDING_URL_RESOLUTION_AGENT_ID = + f.bingUrlResolutionAgentId; + if (f.bingUrlResolutionConnectionId !== undefined) + out.BING_WITH_GROUNDING_URL_RESOLUTION_CONNECTION_ID = + f.bingUrlResolutionConnectionId; + if (f.validatorAgentId !== undefined) + out.AZURE_FOUNDRY_AGENT_ID_VALIDATOR = f.validatorAgentId; + if (f.aliasKeywordExtractorAgentId !== undefined) + out.AZURE_FOUNDRY_AGENT_ID_ALIAS_KEYWORD_EXTRACTOR = + f.aliasKeywordExtractorAgentId; + if (f.openPhraseGeneratorAgentId !== undefined) + out.AZURE_FOUNDRY_AGENT_ID_OPEN_PHRASE_GENERATOR = + f.openPhraseGeneratorAgentId; + if (f.httpEndpointLogicAppConnectionId !== undefined) + out.LOGIC_APP_CONNECTION_ID_GET_HTTP_ENDPOINT = + f.httpEndpointLogicAppConnectionId; + } + + // Extra: untyped passthrough. Last so it can override typed values + // for keys we haven't migrated yet (the user wrote them explicitly). + for (const [k, v] of config.extra) { + out[k] = v; + } + + return out; +} + +function emitOpenAIVariant( + out: EnvOutput, + o: { + apiKey: string; + endpoint?: string | undefined; + endpointEmbedding?: string | undefined; + model?: string | undefined; + modelEmbedding?: string | undefined; + organization?: string | undefined; + responseFormat: boolean; + maxConcurrency: number; + maxTimeoutMs: number; + maxRetryAttempts: number; + }, + suffix: string, +): void { + // Skip the synthetic empty main variant created when only LOCAL is + // configured — it has no apiKey and would emit garbage env vars. + if (suffix === "" && o.apiKey === "") return; + out[`OPENAI_API_KEY${suffix}`] = o.apiKey; + if (o.endpoint !== undefined) out[`OPENAI_ENDPOINT${suffix}`] = o.endpoint; + if (o.endpointEmbedding !== undefined) + out[`OPENAI_ENDPOINT_EMBEDDING${suffix}`] = o.endpointEmbedding; + if (o.model !== undefined) out[`OPENAI_MODEL${suffix}`] = o.model; + if (o.modelEmbedding !== undefined) + out[`OPENAI_MODEL_EMBEDDING${suffix}`] = o.modelEmbedding; + if (o.organization !== undefined) + out[`OPENAI_ORGANIZATION${suffix}`] = o.organization; + out[`OPENAI_RESPONSE_FORMAT${suffix}`] = o.responseFormat ? "1" : "0"; + out[`OPENAI_MAX_CONCURRENCY${suffix}`] = String(o.maxConcurrency); + out[`OPENAI_MAX_TIMEOUT${suffix}`] = String(o.maxTimeoutMs); + out[`OPENAI_MAX_RETRYATTEMPTS${suffix}`] = String(o.maxRetryAttempts); +} + +/** + * Apply the env projection of `config` to `process.env` (or any other + * env-style record). By default does NOT overwrite existing values — + * matches the long-standing loader convention that explicit `process.env` + * wins over file-based config. + */ +export function applyToProcessEnv( + config: Config, + options: { + target?: NodeJS.ProcessEnv; + overwrite?: boolean; + } = {}, +): void { + const target = options.target ?? process.env; + const overwrite = options.overwrite ?? false; + const projected = configToEnv(config); + for (const [k, v] of Object.entries(projected)) { + if (!overwrite && target[k] !== undefined) continue; + target[k] = v; + } +} diff --git a/ts/packages/config/src/runtime/tree.ts b/ts/packages/config/src/runtime/tree.ts new file mode 100644 index 0000000000..d3054428eb --- /dev/null +++ b/ts/packages/config/src/runtime/tree.ts @@ -0,0 +1,1013 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Bidirectional conversion between the typed `Config` and the + * hierarchical YAML tree shape that the importer emits and that + * `flatten()` understands. + * + * buildConfig(flat) -> Config (build.ts) + * configToTree(Config) -> ConfigTree (this file, for YAML emit) + * treeToFlat(ConfigTree) -> FlatEnv (this file, used by flatten()) + * + * The YAML tree mirrors `Config` structurally: each section becomes a + * top-level object whose keys match the typed field names. Maps + * (deployments, endpoints) become plain objects keyed by their + * canonical name / region. Auth that equals the section default is + * omitted to keep the YAML readable; it is restored during flattening + * by inheriting the section's `defaultAuth`. + */ + +import type { ConfigTree, FlatEnv } from "../types.js"; +import { regionFromUrl, regionToEnvSuffix } from "./regions.js"; +import { buildConfig } from "./build.js"; +import { + AuthMode, + Config, + Deployment, + DeploymentEndpoint, + DeploymentMode, + IDENTITY, + authModeFromString, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// configToTree: typed Config -> YAML-friendly object +// --------------------------------------------------------------------------- + +function authToYaml(auth: AuthMode): string { + return auth.kind === "identity" ? "identity" : auth.value; +} + +function authEquals(a: AuthMode, b: AuthMode): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === "identity") return true; + return a.value === (b as { kind: "key"; value: string }).value; +} + +function endpointToYaml( + ep: DeploymentEndpoint, + sectionDefaultAuth: AuthMode, + deploymentDefaultCapacity?: number, +): ConfigTree { + const out: ConfigTree = { endpoint: ep.endpoint }; + if (!authEquals(ep.auth, sectionDefaultAuth)) { + out.auth = authToYaml(ep.auth); + } + // Omit region when it can be auto-derived from the URL host. + if (regionFromUrl(ep.endpoint) !== ep.region) { + out.region = ep.region; + } + if (ep.mode === "PTU") out.mode = "PTU"; + // capacity emission: omit if it matches the deployment default, + // emit `null` to opt out when the endpoint has no capacity but + // a deployment default exists, otherwise emit the value. + if (deploymentDefaultCapacity !== undefined) { + if (ep.capacity === undefined) { + out.capacity = null; + } else if (ep.capacity !== deploymentDefaultCapacity) { + out.capacity = ep.capacity; + } + } else if (ep.capacity !== undefined) { + out.capacity = ep.capacity; + } + if (ep.tpm !== undefined) out.tpm = ep.tpm; + // Only emit priority when it differs from the mode default + // (PTU=1, PAYG=2). Keeps the YAML quiet for the common case. + const defaultPriority = ep.mode === "PTU" ? 1 : 2; + if (ep.priority !== defaultPriority) out.priority = ep.priority; + return out; +} + +/** + * Pick the most-frequent defined capacity. Hoists when at least two + * endpoints share the same value. Endpoints that don't share it (or + * have none) are encoded explicitly via `capacity: ` or + * `capacity: null` respectively, so round-trip is exact. + */ +function pickDefaultCapacity( + vals: ReadonlyArray, +): number | undefined { + const counts = new Map(); + for (const v of vals) { + if (v === undefined) continue; + counts.set(v, (counts.get(v) ?? 0) + 1); + } + let best: number | undefined; + let bestCount = 0; + for (const [v, c] of counts) { + if (c > bestCount) { + best = v; + bestCount = c; + } + } + return bestCount >= 2 ? best : undefined; +} + +/** + * Find the highest-capacity endpoint among the `embedding` deployment's + * endpoints in a raw YAML deployments node. Used to synthesize a default + * bare embedding endpoint when `azureOpenAI.defaultEmbedding` is absent. + */ +function pickDefaultEmbeddingEndpoint(deploymentsNode: unknown): + | { + endpoint: string; + auth?: AuthMode; + } + | undefined { + if (deploymentsNode === null || typeof deploymentsNode !== "object") { + return undefined; + } + const deployments = deploymentsNode as Record; + const node = deployments.embedding; + if (node === undefined) return undefined; + const arr: unknown = Array.isArray(node) + ? node + : typeof node === "object" && node !== null + ? (node as Record).endpoints + : undefined; + if (!Array.isArray(arr) || arr.length === 0) return undefined; + let bestEndpoint: string | undefined; + let bestAuth: AuthMode | undefined; + let bestCapacity = -Infinity; + for (let i = 0; i < arr.length; i++) { + const item = arr[i]; + if (item === null || typeof item !== "object") continue; + const o = item as Record; + const ep = o.endpoint; + if (typeof ep !== "string") continue; + const cap = typeof o.capacity === "number" ? o.capacity : (0 as number); + if (cap > bestCapacity) { + bestCapacity = cap; + bestEndpoint = ep; + bestAuth = + o.auth !== undefined + ? readAuth( + o.auth, + `azureOpenAI.deployments.embedding[${i}].auth`, + ) + : undefined; + } + } + if (bestEndpoint === undefined) return undefined; + return bestAuth !== undefined + ? { endpoint: bestEndpoint, auth: bestAuth } + : { endpoint: bestEndpoint }; +} + +function deploymentToYaml( + d: Deployment, + sectionDefaultAuth: AuthMode, + sectionDefaultCapacity?: number, +): ConfigTree | ConfigTree[] { + const deploymentDefault = pickDefaultCapacity( + d.endpoints.map((e) => e.capacity), + ); + // Use the deployment-level default for endpoint emission so that + // endpoints matching it can be omitted. But only emit the + // deployment-level key in YAML when it differs from the section + // default — otherwise the section default already covers it. + const effectiveDefault = deploymentDefault ?? sectionDefaultCapacity; + const eps = d.endpoints.map((ep) => + endpointToYaml(ep, sectionDefaultAuth, effectiveDefault), + ); + if ( + deploymentDefault !== undefined && + deploymentDefault !== sectionDefaultCapacity + ) { + return { defaultCapacity: deploymentDefault, endpoints: eps }; + } + // If the deployment default matches the section default (or there + // is none), a bare array suffices. + if (effectiveDefault !== undefined && eps.length > 0) { + // We still need the object form if any endpoint has + // explicit capacity values (non-default) — but that's already + // encoded inside each endpoint entry. Just wrap when we used + // an effective default so that round-trip stays correct when + // the section default covers the deployment. + } + return eps; +} + +function bareEndpointToYaml( + ep: DeploymentEndpoint, + sectionDefaultAuth: AuthMode, +): ConfigTree { + // Bare service endpoints (defaultEmbedding/defaultImage/defaultVideo) + // synthesize a sentinel region; we omit region/mode/priority from + // the YAML to keep them tidy, only emitting endpoint and (if + // different) auth. + const out: ConfigTree = { endpoint: ep.endpoint }; + if (!authEquals(ep.auth, sectionDefaultAuth)) { + out.auth = authToYaml(ep.auth); + } + return out; +} + +/** + * Project a typed `Config` to a hierarchical YAML-friendly object. + * The result is suitable for `js-yaml.dump`. + */ +export function configToTree(config: Config): ConfigTree { + const tree: ConfigTree = {}; + const ao = config.azureOpenAI; + const azure: ConfigTree = { + defaultAuth: authToYaml(ao.defaultAuth), + maxConcurrency: ao.maxConcurrency, + maxTimeoutMs: ao.maxTimeoutMs, + maxRetryAttempts: ao.maxRetryAttempts, + responseFormat: ao.responseFormat, + }; + if (ao.enableModelRequestLogging) { + azure.enableModelRequestLogging = true; + } + if (ao.maxPromptChars !== undefined) { + azure.maxPromptChars = ao.maxPromptChars; + } + if (ao.defaultChat) { + azure.defaultChat = bareEndpointToYaml(ao.defaultChat, ao.defaultAuth); + } + if (ao.defaultEmbedding) { + azure.defaultEmbedding = bareEndpointToYaml( + ao.defaultEmbedding, + ao.defaultAuth, + ); + } + if (ao.defaultImage) { + azure.defaultImage = bareEndpointToYaml( + ao.defaultImage, + ao.defaultAuth, + ); + } + if (ao.defaultVideo) { + azure.defaultVideo = bareEndpointToYaml( + ao.defaultVideo, + ao.defaultAuth, + ); + } + if (ao.deployments.size > 0) { + // Derive section-level defaultCapacity: use the explicit value + // from the Config, or pick the most frequent deployment-level + // default across all deployments. + let sectionDefaultCapacity = ao.defaultCapacity; + if (sectionDefaultCapacity === undefined) { + const depDefaults = [...ao.deployments.values()].map((d) => + pickDefaultCapacity(d.endpoints.map((e) => e.capacity)), + ); + sectionDefaultCapacity = pickDefaultCapacity(depDefaults); + } + if (sectionDefaultCapacity !== undefined) { + azure.defaultCapacity = sectionDefaultCapacity; + } + const deployments: ConfigTree = {}; + const sortedNames = [...ao.deployments.keys()].sort(); + for (const name of sortedNames) { + const d = ao.deployments.get(name)!; + deployments[name] = deploymentToYaml( + d, + ao.defaultAuth, + sectionDefaultCapacity, + ); + } + azure.deployments = deployments; + } + tree.azureOpenAI = azure; + + if (config.openAI) { + const o = config.openAI; + const openAI: ConfigTree = {}; + // Emit main fields only when a real (non-synthetic) variant + // is configured — a stub with empty apiKey means only `local` + // is set and the main fields are noise. + if (o.apiKey !== "") { + openAI.apiKey = o.apiKey; + if (o.endpoint !== undefined) openAI.endpoint = o.endpoint; + if (o.endpointEmbedding !== undefined) + openAI.endpointEmbedding = o.endpointEmbedding; + if (o.model !== undefined) openAI.model = o.model; + if (o.modelEmbedding !== undefined) + openAI.modelEmbedding = o.modelEmbedding; + if (o.organization !== undefined) + openAI.organization = o.organization; + openAI.responseFormat = o.responseFormat; + openAI.maxConcurrency = o.maxConcurrency; + openAI.maxTimeoutMs = o.maxTimeoutMs; + openAI.maxRetryAttempts = o.maxRetryAttempts; + } + if (o.local) { + const l = o.local; + const local: ConfigTree = { apiKey: l.apiKey }; + if (l.endpoint !== undefined) local.endpoint = l.endpoint; + if (l.endpointEmbedding !== undefined) + local.endpointEmbedding = l.endpointEmbedding; + if (l.model !== undefined) local.model = l.model; + if (l.modelEmbedding !== undefined) + local.modelEmbedding = l.modelEmbedding; + if (l.organization !== undefined) + local.organization = l.organization; + // Only emit non-default tunables to keep YAML quiet. + if (l.responseFormat) local.responseFormat = l.responseFormat; + if (l.maxConcurrency !== 4) local.maxConcurrency = l.maxConcurrency; + if (l.maxTimeoutMs !== 60_000) local.maxTimeoutMs = l.maxTimeoutMs; + if (l.maxRetryAttempts !== 3) + local.maxRetryAttempts = l.maxRetryAttempts; + openAI.local = local; + } + if (Object.keys(openAI).length > 0) tree.openAI = openAI; + } + + if (config.speech) { + const s: ConfigTree = { + auth: authToYaml(config.speech.auth), + region: config.speech.region, + }; + if (config.speech.endpoint !== undefined) + s.endpoint = config.speech.endpoint; + tree.speech = s; + } + + if (config.maps) { + tree.maps = { + clientId: config.maps.clientId, + endpoint: config.maps.endpoint, + }; + } + + if (config.msGraph) { + const m: ConfigTree = { + clientId: config.msGraph.clientId, + clientSecret: config.msGraph.clientSecret, + tenantId: config.msGraph.tenantId, + }; + if (config.msGraph.username !== undefined) + m.username = config.msGraph.username; + if (config.msGraph.password !== undefined) + m.password = config.msGraph.password; + tree.msGraph = m; + } + + if (config.googleCalendar) { + tree.googleCalendar = { + clientId: config.googleCalendar.clientId, + clientSecret: config.googleCalendar.clientSecret, + }; + } + + if (config.spotify) { + tree.spotify = { + clientId: config.spotify.clientId, + clientSecret: config.spotify.clientSecret, + port: config.spotify.port, + }; + } + + if (config.wikipedia) { + const w: ConfigTree = {}; + if (config.wikipedia.clientId) w.clientId = config.wikipedia.clientId; + if (config.wikipedia.clientSecret) + w.clientSecret = config.wikipedia.clientSecret; + if (config.wikipedia.endpoint) w.endpoint = config.wikipedia.endpoint; + tree.wikipedia = w; + } + + const storage: ConfigTree = {}; + if (config.storage.azure) { + storage.azure = { + account: config.storage.azure.account, + container: config.storage.azure.container, + }; + } + if (config.storage.aws) { + storage.aws = { + bucketName: config.storage.aws.bucketName, + region: config.storage.aws.region, + accessKeyId: config.storage.aws.accessKeyId, + secretAccessKey: config.storage.aws.secretAccessKey, + }; + } + if (config.storage.database) { + const db: ConfigTree = {}; + if (config.storage.database.cosmosDbConnectionString !== undefined) + db.cosmosDbConnectionString = + config.storage.database.cosmosDbConnectionString; + if (config.storage.database.mongoDbConnectionString !== undefined) + db.mongoDbConnectionString = + config.storage.database.mongoDbConnectionString; + storage.database = db; + } + if (config.storage.elastic) { + storage.elastic = { + apiKey: config.storage.elastic.apiKey, + uri: config.storage.elastic.uri, + }; + } + if (Object.keys(storage).length > 0) tree.storage = storage; + + if (config.vault?.shared) { + tree.vault = { shared: config.vault.shared }; + } + + if (config.azureFoundry) { + const f = config.azureFoundry; + const af: ConfigTree = {}; + if (f.bingEndpoint !== undefined) af.bingEndpoint = f.bingEndpoint; + if (f.bingAgentId !== undefined) af.bingAgentId = f.bingAgentId; + if (f.bingUrlResolutionAgentId !== undefined) + af.bingUrlResolutionAgentId = f.bingUrlResolutionAgentId; + if (f.bingUrlResolutionConnectionId !== undefined) + af.bingUrlResolutionConnectionId = f.bingUrlResolutionConnectionId; + if (f.validatorAgentId !== undefined) + af.validatorAgentId = f.validatorAgentId; + if (f.aliasKeywordExtractorAgentId !== undefined) + af.aliasKeywordExtractorAgentId = f.aliasKeywordExtractorAgentId; + if (f.openPhraseGeneratorAgentId !== undefined) + af.openPhraseGeneratorAgentId = f.openPhraseGeneratorAgentId; + if (f.httpEndpointLogicAppConnectionId !== undefined) + af.httpEndpointLogicAppConnectionId = + f.httpEndpointLogicAppConnectionId; + if (Object.keys(af).length > 0) tree.azureFoundry = af; + } + + if (config.reasoning) { + const r: ConfigTree = {}; + if (config.reasoning.timeoutMs !== undefined) + r.timeoutMs = config.reasoning.timeoutMs; + if (config.reasoning.copilotModel !== undefined) + r.copilotModel = config.reasoning.copilotModel; + if (Object.keys(r).length > 0) tree.reasoning = r; + } + + return tree; +} + +// --------------------------------------------------------------------------- +// typedSectionToFlat: YAML subtree -> flat env (used by flatten()) +// --------------------------------------------------------------------------- + +const TYPED_SECTION_KEYS = new Set([ + "azureOpenAI", + "openAI", + "speech", + "maps", + "msGraph", + "googleCalendar", + "spotify", + "wikipedia", + "storage", + "vault", + "azureFoundry", + "reasoning", +]); + +export function isTypedSectionKey(key: string): boolean { + return TYPED_SECTION_KEYS.has(key); +} + +/** + * Convenience wrapper: flat env vars → YAML-friendly tree. + * Combines `buildConfig` and `configToTree` in one call. + */ +export function envToYamlTree(flat: FlatEnv): ConfigTree { + return configToTree(buildConfig(flat)); +} + +function asObject(node: unknown, where: string): Record { + if (node === null || typeof node !== "object" || Array.isArray(node)) { + throw new Error( + `Expected an object at '${where}', got ${typeof node}.`, + ); + } + return node as Record; +} + +function asString(node: unknown, where: string): string { + if (typeof node !== "string") { + throw new Error(`Expected a string at '${where}', got ${typeof node}.`); + } + return node; +} + +function asNumber(node: unknown, where: string): number { + if (typeof node !== "number" || !Number.isFinite(node)) { + throw new Error( + `Expected a number at '${where}', got ${JSON.stringify(node)}.`, + ); + } + return node; +} + +function readAuth(node: unknown, where: string): AuthMode { + return authModeFromString(asString(node, where)); +} + +function readEndpointEntry( + node: unknown, + where: string, +): { + endpoint: string; + auth?: AuthMode; + mode?: DeploymentMode; + priority?: number; + capacity?: number | null; + region?: string; + tpm?: number; +} { + const obj = asObject(node, where); + const out: ReturnType = { + endpoint: asString(obj.endpoint, `${where}.endpoint`), + }; + if (obj.auth !== undefined) out.auth = readAuth(obj.auth, `${where}.auth`); + if (obj.region !== undefined) + out.region = asString(obj.region, `${where}.region`); + if (obj.mode !== undefined) { + const m = asString(obj.mode, `${where}.mode`).toUpperCase(); + if (m !== "PAYG" && m !== "PTU") { + throw new Error( + `Invalid mode at '${where}.mode': expected PAYG or PTU.`, + ); + } + out.mode = m; + } + if (obj.priority !== undefined) + out.priority = asNumber(obj.priority, `${where}.priority`); + // capacity may be a number, or explicitly `null` to opt out of + // a deployment-level or section-level defaultCapacity. Missing means inherit. + if (obj.capacity === null) { + out.capacity = null; + } else if (obj.capacity !== undefined) { + out.capacity = asNumber(obj.capacity, `${where}.capacity`); + } + if (obj.tpm !== undefined) out.tpm = asNumber(obj.tpm, `${where}.tpm`); + return out; +} + +function emitAzureOpenAI(node: unknown, out: FlatEnv): void { + const obj = asObject(node, "azureOpenAI"); + const defaultAuth: AuthMode = + obj.defaultAuth !== undefined + ? readAuth(obj.defaultAuth, "azureOpenAI.defaultAuth") + : IDENTITY; + out.AZURE_OPENAI_API_KEY = authToYaml(defaultAuth); + + if (obj.maxConcurrency !== undefined) + out.AZURE_OPENAI_MAX_CONCURRENCY = String( + asNumber(obj.maxConcurrency, "azureOpenAI.maxConcurrency"), + ); + if (obj.maxTimeoutMs !== undefined) + out.AZURE_OPENAI_MAX_TIMEOUT = String( + asNumber(obj.maxTimeoutMs, "azureOpenAI.maxTimeoutMs"), + ); + if (obj.maxRetryAttempts !== undefined) + out.AZURE_OPENAI_MAX_RETRYATTEMPTS = String( + asNumber(obj.maxRetryAttempts, "azureOpenAI.maxRetryAttempts"), + ); + if (obj.responseFormat !== undefined) + out.AZURE_OPENAI_RESPONSE_FORMAT = obj.responseFormat ? "1" : "0"; + if (obj.maxPromptChars !== undefined) + out.AZURE_OPENAI_MAX_CHARS = String( + asNumber(obj.maxPromptChars, "azureOpenAI.maxPromptChars"), + ); + if (obj.enableModelRequestLogging) + out.ENABLE_MODEL_REQUEST_LOGGING = "true"; + + function emitBare(serviceName: string, suffix: string | null): void { + if (obj[serviceName] === undefined) return; + const where = `azureOpenAI.${serviceName}`; + const o = asObject(obj[serviceName], where); + const ep = asString(o.endpoint, `${where}.endpoint`); + const auth = + o.auth !== undefined + ? readAuth(o.auth, `${where}.auth`) + : defaultAuth; + if (suffix === null) { + out.AZURE_OPENAI_ENDPOINT = ep; + } else { + out[`AZURE_OPENAI_ENDPOINT_${suffix}`] = ep; + out[`AZURE_OPENAI_API_KEY_${suffix}`] = authToYaml(auth); + } + } + emitBare("defaultChat", null); + emitBare("defaultEmbedding", "EMBEDDING"); + emitBare("defaultImage", "GPT_IMAGE_1_5"); + emitBare("defaultVideo", "SORA_2"); + + // Auto-synthesize the bare embedding endpoint from the highest-capacity + // `embedding` deployment endpoint when no explicit `defaultEmbedding` is + // configured. This lets callers that look up the bare + // AZURE_OPENAI_ENDPOINT_EMBEDDING env var (legacy path) succeed without + // requiring users to repeat the endpoint in `defaultEmbedding:`. + if (out.AZURE_OPENAI_ENDPOINT_EMBEDDING === undefined) { + const auto = pickDefaultEmbeddingEndpoint(obj.deployments); + if (auto !== undefined) { + out.AZURE_OPENAI_ENDPOINT_EMBEDDING = auto.endpoint; + out.AZURE_OPENAI_API_KEY_EMBEDDING = authToYaml( + auto.auth ?? defaultAuth, + ); + } + } + + // Section-level defaultCapacity — inherited by all deployments + // that don't specify their own. + let sectionDefaultCapacity: number | undefined; + if (obj.defaultCapacity !== undefined) + sectionDefaultCapacity = asNumber( + obj.defaultCapacity, + "azureOpenAI.defaultCapacity", + ); + + if (obj.deployments !== undefined) { + const dmap = asObject(obj.deployments, "azureOpenAI.deployments"); + for (const [name, dnode] of Object.entries(dmap)) { + const where = `azureOpenAI.deployments.${name}`; + // Two accepted shapes: + // - array of endpoints (no per-deployment defaults) + // - object: { defaultCapacity?, endpoints: [...] } + let endpointsNode: unknown; + let defaultCapacity: number | undefined; + if (Array.isArray(dnode)) { + endpointsNode = dnode; + } else { + const dobj = asObject(dnode, where); + if (dobj.defaultCapacity !== undefined) + defaultCapacity = asNumber( + dobj.defaultCapacity, + `${where}.defaultCapacity`, + ); + endpointsNode = dobj.endpoints; + if (!Array.isArray(endpointsNode)) { + throw new Error( + `Expected an array at '${where}.endpoints'.`, + ); + } + } + // Fall back to section-level defaultCapacity when the + // deployment doesn't specify its own. + if (defaultCapacity === undefined) + defaultCapacity = sectionDefaultCapacity; + const arr = endpointsNode as unknown[]; + const upperName = name.toUpperCase(); + const overrides: Array> = []; + for (let i = 0; i < arr.length; i++) { + const ewhere = `${where}[${i}]`; + const ep = readEndpointEntry(arr[i], ewhere); + // Resolve effective capacity: + // - explicit number -> use it + // - explicit null -> no capacity (opt out) + // - missing & default -> inherit deployment or section defaultCapacity + // - missing & no default-> no capacity + let capacity: number | undefined; + if (typeof ep.capacity === "number") { + capacity = ep.capacity; + } else if (ep.capacity === null) { + capacity = undefined; + } else { + capacity = defaultCapacity; + } + const region = ep.region ?? regionFromUrl(ep.endpoint); + if (!region) { + throw new Error( + `Could not derive region for ${ewhere}; ` + + `add a 'region:' property.`, + ); + } + const mode = ep.mode ?? "PAYG"; + const regionToken = regionToEnvSuffix(region as never); + const suffix = + mode === "PTU" + ? `${upperName}_${regionToken}_PTU` + : `${upperName}_${regionToken}`; + out[`AZURE_OPENAI_ENDPOINT_${suffix}`] = ep.endpoint; + out[`AZURE_OPENAI_API_KEY_${suffix}`] = authToYaml( + ep.auth ?? defaultAuth, + ); + const defPriority = mode === "PTU" ? 1 : 2; + const priority = ep.priority ?? defPriority; + if ( + capacity !== undefined || + ep.tpm !== undefined || + priority !== defPriority + ) { + const o: Record = { + suffix, + region, + mode, + }; + if (capacity !== undefined) o.capacity = capacity; + if (ep.tpm !== undefined) o.tpm = ep.tpm; + o.priority = priority; + overrides.push(o); + } + } + if (overrides.length > 0) { + const body = overrides + .map( + (o) => + "{" + + Object.entries(o) + .map(([k, v]) => `${k}:${v}`) + .join(",") + + "}", + ) + .join(","); + out[`AZURE_OPENAI_POOL_${upperName}`] = `[${body}]`; + } + } + } +} + +function emitOpenAI(node: unknown, out: FlatEnv): void { + const o = asObject(node, "openAI"); + emitOpenAIVariant(o, out, "", "openAI"); + if (o.local !== undefined) { + const lo = asObject(o.local, "openAI.local"); + emitOpenAIVariant(lo, out, "_LOCAL", "openAI.local"); + // Emit OLLAMA_ENDPOINT alias so consumers reading that legacy + // env var pick up the openAI.local endpoint automatically. + if (lo.endpoint !== undefined) + out.OLLAMA_ENDPOINT = asString( + lo.endpoint, + "openAI.local.endpoint", + ); + } +} + +function emitOpenAIVariant( + o: Record, + out: FlatEnv, + suffix: string, + where: string, +): void { + if (o.apiKey !== undefined) + out[`OPENAI_API_KEY${suffix}`] = asString(o.apiKey, `${where}.apiKey`); + if (o.endpoint !== undefined) + out[`OPENAI_ENDPOINT${suffix}`] = asString( + o.endpoint, + `${where}.endpoint`, + ); + if (o.endpointEmbedding !== undefined) + out[`OPENAI_ENDPOINT_EMBEDDING${suffix}`] = asString( + o.endpointEmbedding, + `${where}.endpointEmbedding`, + ); + if (o.model !== undefined) + out[`OPENAI_MODEL${suffix}`] = asString(o.model, `${where}.model`); + if (o.modelEmbedding !== undefined) + out[`OPENAI_MODEL_EMBEDDING${suffix}`] = asString( + o.modelEmbedding, + `${where}.modelEmbedding`, + ); + if (o.organization !== undefined) + out[`OPENAI_ORGANIZATION${suffix}`] = asString( + o.organization, + `${where}.organization`, + ); + if (o.responseFormat !== undefined) + out[`OPENAI_RESPONSE_FORMAT${suffix}`] = o.responseFormat ? "1" : "0"; + if (o.maxConcurrency !== undefined) + out[`OPENAI_MAX_CONCURRENCY${suffix}`] = String( + asNumber(o.maxConcurrency, `${where}.maxConcurrency`), + ); + if (o.maxTimeoutMs !== undefined) + out[`OPENAI_MAX_TIMEOUT${suffix}`] = String( + asNumber(o.maxTimeoutMs, `${where}.maxTimeoutMs`), + ); + if (o.maxRetryAttempts !== undefined) + out[`OPENAI_MAX_RETRYATTEMPTS${suffix}`] = String( + asNumber(o.maxRetryAttempts, `${where}.maxRetryAttempts`), + ); +} + +function emitSpeech(node: unknown, out: FlatEnv): void { + const s = asObject(node, "speech"); + if (s.auth !== undefined) + out.SPEECH_SDK_KEY = authToYaml(readAuth(s.auth, "speech.auth")); + if (s.region !== undefined) + out.SPEECH_SDK_REGION = asString(s.region, "speech.region"); + if (s.endpoint !== undefined) + out.SPEECH_SDK_ENDPOINT = asString(s.endpoint, "speech.endpoint"); +} + +function emitMaps(node: unknown, out: FlatEnv): void { + const m = asObject(node, "maps"); + if (m.clientId !== undefined) + out.AZURE_MAPS_CLIENTID = asString(m.clientId, "maps.clientId"); + if (m.endpoint !== undefined) + out.AZURE_MAPS_ENDPOINT = asString(m.endpoint, "maps.endpoint"); +} + +function emitMsGraph(node: unknown, out: FlatEnv): void { + const m = asObject(node, "msGraph"); + if (m.clientId !== undefined) + out.MSGRAPH_APP_CLIENTID = asString(m.clientId, "msGraph.clientId"); + if (m.clientSecret !== undefined) + out.MSGRAPH_APP_CLIENTSECRET = asString( + m.clientSecret, + "msGraph.clientSecret", + ); + if (m.tenantId !== undefined) + out.MSGRAPH_APP_TENANTID = asString(m.tenantId, "msGraph.tenantId"); + if (m.username !== undefined) + out.MSGRAPH_APP_USERNAME = asString(m.username, "msGraph.username"); + if (m.password !== undefined) + out.MSGRAPH_APP_PASSWD = asString(m.password, "msGraph.password"); +} + +function emitGoogleCalendar(node: unknown, out: FlatEnv): void { + const g = asObject(node, "googleCalendar"); + if (g.clientId !== undefined) + out.GOOGLE_CALENDAR_CLIENT_ID = asString( + g.clientId, + "googleCalendar.clientId", + ); + if (g.clientSecret !== undefined) + out.GOOGLE_CALENDAR_CLIENT_SECRET = asString( + g.clientSecret, + "googleCalendar.clientSecret", + ); +} + +function emitSpotify(node: unknown, out: FlatEnv): void { + const s = asObject(node, "spotify"); + if (s.clientId !== undefined) + out.SPOTIFY_APP_CLI = asString(s.clientId, "spotify.clientId"); + if (s.clientSecret !== undefined) + out.SPOTIFY_APP_CLISEC = asString( + s.clientSecret, + "spotify.clientSecret", + ); + if (s.port !== undefined) + out.SPOTIFY_APP_PORT = String(asNumber(s.port, "spotify.port")); +} + +function emitWikipedia(node: unknown, out: FlatEnv): void { + const w = asObject(node, "wikipedia"); + if (w.clientId !== undefined) + out.WIKIPEDIA_CLIENT_ID = asString(w.clientId, "wikipedia.clientId"); + if (w.clientSecret !== undefined) + out.WIKIPEDIA_CLIENT_SECRET = asString( + w.clientSecret, + "wikipedia.clientSecret", + ); + if (w.endpoint !== undefined) + out.WIKIPEDIA_ENDPOINT = asString(w.endpoint, "wikipedia.endpoint"); +} + +function emitStorage(node: unknown, out: FlatEnv): void { + const s = asObject(node, "storage"); + if (s.azure !== undefined) { + const a = asObject(s.azure, "storage.azure"); + if (a.account !== undefined) + out.AZURE_STORAGE_ACCOUNT = asString( + a.account, + "storage.azure.account", + ); + if (a.container !== undefined) + out.AZURE_STORAGE_CONTAINER = asString( + a.container, + "storage.azure.container", + ); + } + if (s.aws !== undefined) { + const a = asObject(s.aws, "storage.aws"); + if (a.bucketName !== undefined) + out.AWS_S3_BUCKET_NAME = asString( + a.bucketName, + "storage.aws.bucketName", + ); + if (a.region !== undefined) + out.AWS_S3_REGION = asString(a.region, "storage.aws.region"); + if (a.accessKeyId !== undefined) + out.AWS_ACCESS_KEY_ID = asString( + a.accessKeyId, + "storage.aws.accessKeyId", + ); + if (a.secretAccessKey !== undefined) + out.AWS_SECRET_ACCESS_KEY = asString( + a.secretAccessKey, + "storage.aws.secretAccessKey", + ); + } + if (s.database !== undefined) { + const d = asObject(s.database, "storage.database"); + if (d.cosmosDbConnectionString !== undefined) + out.COSMOSDB_CONNECTION_STRING = asString( + d.cosmosDbConnectionString, + "storage.database.cosmosDbConnectionString", + ); + if (d.mongoDbConnectionString !== undefined) + out.MONGODB_CONNECTION_STRING = asString( + d.mongoDbConnectionString, + "storage.database.mongoDbConnectionString", + ); + } + if (s.elastic !== undefined) { + const e = asObject(s.elastic, "storage.elastic"); + if (e.apiKey !== undefined) + out.ELASTIC_API_KEY = asString(e.apiKey, "storage.elastic.apiKey"); + if (e.uri !== undefined) + out.ELASTIC_URI = asString(e.uri, "storage.elastic.uri"); + } +} + +function emitVault(node: unknown, out: FlatEnv): void { + const v = asObject(node, "vault"); + if (v.shared !== undefined) + out.TYPEAGENT_SHAREDVAULT = asString(v.shared, "vault.shared"); +} + +function emitAzureFoundry(node: unknown, out: FlatEnv): void { + const f = asObject(node, "azureFoundry"); + const map: Array<[string, string]> = [ + ["bingEndpoint", "BING_WITH_GROUNDING_ENDPOINT"], + ["bingAgentId", "BING_WITH_GROUNDING_AGENT_ID"], + [ + "bingUrlResolutionAgentId", + "BING_WITH_GROUNDING_URL_RESOLUTION_AGENT_ID", + ], + [ + "bingUrlResolutionConnectionId", + "BING_WITH_GROUNDING_URL_RESOLUTION_CONNECTION_ID", + ], + ["validatorAgentId", "AZURE_FOUNDRY_AGENT_ID_VALIDATOR"], + [ + "aliasKeywordExtractorAgentId", + "AZURE_FOUNDRY_AGENT_ID_ALIAS_KEYWORD_EXTRACTOR", + ], + [ + "openPhraseGeneratorAgentId", + "AZURE_FOUNDRY_AGENT_ID_OPEN_PHRASE_GENERATOR", + ], + [ + "httpEndpointLogicAppConnectionId", + "LOGIC_APP_CONNECTION_ID_GET_HTTP_ENDPOINT", + ], + ]; + for (const [yamlKey, envKey] of map) { + if (f[yamlKey] !== undefined) + out[envKey] = asString(f[yamlKey], `azureFoundry.${yamlKey}`); + } +} + +function emitReasoning(node: unknown, out: FlatEnv): void { + const r = asObject(node, "reasoning"); + if (r.timeoutMs !== undefined) + out.TYPEAGENT_REASONING_TIMEOUT_MS = String( + asNumber(r.timeoutMs, "reasoning.timeoutMs"), + ); + if (r.copilotModel !== undefined) + out.COPILOT_REASONING_MODEL = asString( + r.copilotModel, + "reasoning.copilotModel", + ); +} + +/** + * Project a typed top-level subtree (e.g. `azureOpenAI`, `openAI`, + * `speech`, ...) onto its flat env-var equivalents, mirroring + * `configToEnv` but operating on the YAML object form (no Map types, + * regions as object keys). + */ +export function typedSectionToFlat(key: string, node: unknown): FlatEnv { + const out: FlatEnv = {}; + switch (key) { + case "azureOpenAI": + emitAzureOpenAI(node, out); + break; + case "openAI": + emitOpenAI(node, out); + break; + case "speech": + emitSpeech(node, out); + break; + case "maps": + emitMaps(node, out); + break; + case "msGraph": + emitMsGraph(node, out); + break; + case "googleCalendar": + emitGoogleCalendar(node, out); + break; + case "spotify": + emitSpotify(node, out); + break; + case "wikipedia": + emitWikipedia(node, out); + break; + case "storage": + emitStorage(node, out); + break; + case "vault": + emitVault(node, out); + break; + case "azureFoundry": + emitAzureFoundry(node, out); + break; + case "reasoning": + emitReasoning(node, out); + break; + default: + throw new Error(`Not a typed section: '${key}'.`); + } + return out; +} diff --git a/ts/packages/config/src/runtime/types.ts b/ts/packages/config/src/runtime/types.ts new file mode 100644 index 0000000000..9f9f348d6e --- /dev/null +++ b/ts/packages/config/src/runtime/types.ts @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Typed runtime configuration surface. + * + * This is the API consumers will eventually call instead of reading + * `process.env.AZURE_OPENAI_*` directly. It is intentionally: + * + * - **Immutable**: every field is `readonly`. A `Config` is built once + * by the loader and shared. Mutations require a reload. + * - **Discriminated**: `AuthMode` makes "use managed identity" a + * first-class state instead of a magic string `"identity"`. + * - **Sparse**: every section other than `azureOpenAI` and `extra` is + * optional. Consumers that need a section assert its presence at + * their boundary; missing sections produce clean error messages. + * - **Backed by an escape hatch**: `extra` carries any flat env-var + * keys we haven't promoted to typed accessors yet. Phase 3 will + * progressively shrink it. + */ + +import type { Region } from "./regions.js"; + +/** Authentication mode for an Azure resource. */ +export type AuthMode = + | { readonly kind: "identity" } + | { readonly kind: "key"; readonly value: string }; + +export const IDENTITY: AuthMode = { kind: "identity" }; + +export function authModeFromString(s: string | undefined): AuthMode { + if (s === undefined || s === "" || s.toLowerCase() === "identity") { + return IDENTITY; + } + return { kind: "key", value: s }; +} + +/** Whether a deployment is pay-as-you-go or provisioned-throughput. */ +export type DeploymentMode = "PAYG" | "PTU"; + +/** A single regional endpoint of an Azure OpenAI deployment. */ +export interface DeploymentEndpoint { + /** Full chat/completions/embeddings/images URL. */ + readonly endpoint: string; + /** Authentication for this endpoint; falls back to `defaultAuth`. */ + readonly auth: AuthMode; + /** + * Region this endpoint serves. Auto-derived from the URL hostname + * when not explicitly set in YAML; required for cooldown / pool + * accounting. + */ + readonly region: Region; + readonly mode: DeploymentMode; + /** 1 = preferred tier; defaults to 1 for PTU, 2 for PAYG. */ + readonly priority: number; + /** Declared TPM/RPM capacity (informational; used for routing hints). */ + readonly capacity?: number | undefined; + /** Declared TPM (for pool routing weight); from legacy POOL override JSON. */ + readonly tpm?: number | undefined; +} + +/** + * A named Azure OpenAI deployment. The `endpoints` list is in + * priority order (lowest priority value first; ties preserve insertion + * order) and is the canonical pool used for routing. + */ +export interface Deployment { + readonly name: string; + readonly endpoints: readonly DeploymentEndpoint[]; +} + +export interface AzureOpenAIConfig { + /** Auth used by deployments / bare endpoints that don't specify one. */ + readonly defaultAuth: AuthMode; + + readonly maxConcurrency: number; + readonly maxTimeoutMs: number; + readonly maxRetryAttempts: number; + /** Whether to send `response_format: json_object` on chat requests. */ + readonly responseFormat: boolean; + readonly enableModelRequestLogging: boolean; + readonly maxPromptChars?: number | undefined; + + /** + * Section-level default capacity applied to every deployment endpoint + * that doesn't specify its own capacity (either directly on the + * endpoint or via a per-deployment `defaultCapacity`). + */ + readonly defaultCapacity?: number | undefined; + + /** Bare `AZURE_OPENAI_ENDPOINT` (legacy default chat target). */ + readonly defaultChat?: DeploymentEndpoint | undefined; + readonly defaultEmbedding?: DeploymentEndpoint | undefined; + readonly defaultImage?: DeploymentEndpoint | undefined; + readonly defaultVideo?: DeploymentEndpoint | undefined; + + readonly deployments: ReadonlyMap; +} + +export interface OpenAIConfig { + readonly apiKey: string; + readonly endpoint?: string | undefined; + readonly endpointEmbedding?: string | undefined; + readonly model?: string | undefined; + readonly modelEmbedding?: string | undefined; + readonly organization?: string | undefined; + readonly responseFormat: boolean; + readonly maxConcurrency: number; + readonly maxTimeoutMs: number; + readonly maxRetryAttempts: number; + /** Local-OpenAI-API-compatible target (Ollama, etc.). */ + readonly local?: OpenAIConfig | undefined; +} + +export interface SpeechConfig { + readonly auth: AuthMode; + readonly region: Region; + readonly endpoint?: string | undefined; +} + +export interface MapsConfig { + readonly clientId: string; + readonly endpoint: string; +} + +export interface MicrosoftGraphConfig { + readonly clientId: string; + readonly clientSecret: string; + readonly tenantId: string; + readonly username?: string | undefined; + readonly password?: string | undefined; +} + +export interface GoogleCalendarConfig { + readonly clientId: string; + readonly clientSecret: string; +} + +export interface SpotifyConfig { + readonly clientId: string; + readonly clientSecret: string; + readonly port: number; +} + +export interface WikipediaConfig { + readonly clientId: string; + readonly clientSecret: string; + readonly endpoint: string; +} + +export interface AzureStorageConfig { + readonly account: string; + readonly container: string; +} + +export interface AwsStorageConfig { + readonly bucketName: string; + readonly region: string; + readonly accessKeyId: string; + readonly secretAccessKey: string; +} + +export interface ElasticConfig { + readonly apiKey: string; + readonly uri: string; +} + +export interface DatabaseConfig { + readonly cosmosDbConnectionString?: string | undefined; + readonly mongoDbConnectionString?: string | undefined; +} + +export interface StorageConfig { + readonly azure?: AzureStorageConfig | undefined; + readonly aws?: AwsStorageConfig | undefined; + readonly database?: DatabaseConfig | undefined; + readonly elastic?: ElasticConfig | undefined; +} + +export interface VaultConfig { + /** Name of the shared Azure Key Vault (e.g. "aisystems"). */ + readonly shared?: string | undefined; +} + +/** + * Azure AI Foundry configuration: Bing-with-Grounding endpoints, agent + * identifiers, and the Logic-App connection used for HTTP tool dispatch. + * Mirrors the legacy `AZURE_FOUNDRY_*` / `BING_WITH_GROUNDING_*` / + * `LOGIC_APP_CONNECTION_ID_*` env vars. All fields optional so partial + * configurations remain valid. + */ +export interface AzureFoundryConfig { + /** BING_WITH_GROUNDING_ENDPOINT */ + readonly bingEndpoint?: string | undefined; + /** BING_WITH_GROUNDING_AGENT_ID */ + readonly bingAgentId?: string | undefined; + /** BING_WITH_GROUNDING_URL_RESOLUTION_AGENT_ID */ + readonly bingUrlResolutionAgentId?: string | undefined; + /** BING_WITH_GROUNDING_URL_RESOLUTION_CONNECTION_ID */ + readonly bingUrlResolutionConnectionId?: string | undefined; + /** AZURE_FOUNDRY_AGENT_ID_VALIDATOR */ + readonly validatorAgentId?: string | undefined; + /** AZURE_FOUNDRY_AGENT_ID_ALIAS_KEYWORD_EXTRACTOR */ + readonly aliasKeywordExtractorAgentId?: string | undefined; + /** AZURE_FOUNDRY_AGENT_ID_OPEN_PHRASE_GENERATOR */ + readonly openPhraseGeneratorAgentId?: string | undefined; + /** LOGIC_APP_CONNECTION_ID_GET_HTTP_ENDPOINT */ + readonly httpEndpointLogicAppConnectionId?: string | undefined; +} + +export interface ReasoningConfig { + /** Reasoning-loop timeout in milliseconds (0 = disabled). */ + readonly timeoutMs?: number | undefined; + /** Override the default Copilot reasoning model. */ + readonly copilotModel?: string | undefined; +} + +/** Root typed configuration. */ +export interface Config { + readonly azureOpenAI: AzureOpenAIConfig; + readonly openAI?: OpenAIConfig | undefined; + readonly speech?: SpeechConfig | undefined; + readonly maps?: MapsConfig | undefined; + readonly msGraph?: MicrosoftGraphConfig | undefined; + readonly googleCalendar?: GoogleCalendarConfig | undefined; + readonly spotify?: SpotifyConfig | undefined; + readonly wikipedia?: WikipediaConfig | undefined; + readonly storage: StorageConfig; + readonly vault?: VaultConfig | undefined; + readonly azureFoundry?: AzureFoundryConfig | undefined; + readonly reasoning?: ReasoningConfig | undefined; + /** + * Untyped passthrough: any flat `KEY=value` pair that wasn't + * recognized by the typed schema lives here. This is what makes + * the migration incremental — unmigrated consumers can still find + * their values via the compatibility shim. + */ + readonly extra: ReadonlyMap; +} diff --git a/ts/packages/config/src/schema.ts b/ts/packages/config/src/schema.ts new file mode 100644 index 0000000000..22c6b2fa15 --- /dev/null +++ b/ts/packages/config/src/schema.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; + +/** + * Phase 1 schema. Intentionally permissive — the full structured + * schema for `azure.openai.deployments[]`, agent-specific blocks, etc. + * lands with the `.env` importer in Phase 2.7. For now we only enforce + * that: + * + * - The top-level document is a map (not a scalar or array). + * - Leaf values are strings, numbers, booleans, or null. + * - Arrays are allowed, but only as arrays of objects (used for + * `azureOpenAI.deployments.[].endpoints`). + */ +const scalarSchema: z.ZodType = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]); + +const treeSchema: z.ZodType = z.lazy(() => + z.record(z.union([scalarSchema, treeSchema, z.array(treeSchema)])), +); + +export const configTreeSchema = treeSchema; + +/** + * Validate a parsed YAML document against the Phase 1 schema. + * + * @param data Parsed YAML (typically the output of `yaml.load`). + * @param sourceLabel A human-readable label (file path) used in error + * messages. + * @throws An aggregated Error if validation fails. + */ +export function validateConfigTree(data: unknown, sourceLabel: string): void { + const result = configTreeSchema.safeParse(data); + if (result.success) { + return; + } + const issues = result.error.issues + .map((i) => { + const path = i.path.length > 0 ? i.path.join(".") : ""; + return ` - ${path}: ${i.message}`; + }) + .join("\n"); + throw new Error(`Invalid TypeAgent config in ${sourceLabel}:\n${issues}`); +} diff --git a/ts/packages/config/src/tsconfig.json b/ts/packages/config/src/tsconfig.json new file mode 100644 index 0000000000..5e8e822c61 --- /dev/null +++ b/ts/packages/config/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist" + }, + "include": ["./**/*"] +} diff --git a/ts/packages/config/src/types.ts b/ts/packages/config/src/types.ts new file mode 100644 index 0000000000..5bc3a05aa4 --- /dev/null +++ b/ts/packages/config/src/types.ts @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Phase 1 type definitions for the layered YAML configuration loader. + * + * Later phases (Key Vault fetch, encrypted cache, structured deployment + * arrays via the importer) extend these types — the current shape is + * intentionally permissive so users can author YAML before the full + * structured schema lands. + */ + +/** + * A leaf value in a YAML configuration tree. Anything else (objects, + * arrays, nulls) is structural and handled by the flattener. + */ +export type ConfigScalar = string | number | boolean; + +/** + * A node in the in-memory representation of a parsed YAML config file. + * Maps are nested arbitrarily deep; leaves are scalars (or nulls, which + * are dropped during flattening). + */ +export type ConfigTree = { + [key: string]: ConfigScalar | ConfigTree | string[] | ConfigTree[] | null; +}; + +/** + * A flat environment-variable map of the shape `process.env` uses. + * The flattener produces this from a `ConfigTree`; the loader merges it + * into `process.env` for backwards compatibility with existing + * `getEnvSetting` consumers in aiclient. + */ +export type FlatEnv = Record; + +/** + * Origin of a resolved configuration value. Used by the loader for + * source-aware diagnostics (`typeagent config show --source`, planned + * for Phase 2.7) and for the straggler-catcher in Phase 3. + */ +export enum ConfigSource { + Defaults = "defaults", + KeyVault = "key-vault", + Cache = "cache", + Local = "local", + DotEnv = "dotenv", + ProcessEnv = "process-env", +} + +/** + * Per-key provenance: which source supplied the final value for each + * flat env key. Populated by the loader when source tracking is enabled. + */ +export type SourceMap = Record; + +/** + * Options for fetching a YAML configuration blob from Azure Key Vault. + * + * The default vault for TypeAgent dev work is `aisystems`; CI workflows + * pass an explicit vault name. The default secret name is + * `typeagent-config` (defined in `keyVault.ts`). + */ +export interface KeyVaultOptions { + /** + * Azure Key Vault name (e.g., `aisystems`). When omitted, the + * loader auto-discovers the vault name from the `vault.shared` + * key in the already-loaded defaults / local layers (which + * flattens to `TYPEAGENT_SHAREDVAULT`). If no vault name can + * be resolved, the Key Vault layer is silently skipped. + */ + vaultName?: string; + + /** + * Secret name holding the YAML blob. Defaults to + * `typeagent-config`. + */ + secretName?: string; + + /** + * Custom Azure credential. Defaults to `DefaultAzureCredential`, + * which mirrors the rest of the TypeAgent codebase. + */ + credential?: import("@azure/identity").TokenCredential; + + /** + * Custom fetcher (vault, secret) -> raw string | null. Used by + * tests to bypass the live Azure SDK; production callers should + * leave this undefined. + */ + fetcher?: (vaultName: string, secretName: string) => Promise; + + /** + * If `true`, throw on fetch / parse / validation errors. If + * `false` (the default), errors are logged and the layer is + * skipped — the loader falls through to lower-precedence layers, + * matching the offline-friendly behavior the migration plan + * requires. + */ + failOnError?: boolean; +} + +/** + * Options accepted by `loadConfig`. All fields are optional; sensible + * defaults locate `config.defaults.yaml` / `config.local.yaml` / `.env` + * relative to the TypeAgent `ts/` workspace root. + */ +export interface LoadConfigOptions { + /** + * Workspace root used to resolve relative file paths. Defaults to + * the `ts/` directory inferred from this package's location. + */ + workspaceRoot?: string; + + /** + * Path to the committed defaults file. Defaults to + * `/config.defaults.yaml`. + */ + defaultsPath?: string; + + /** + * Path to the gitignored local override file. Defaults to + * `/config.local.yaml`. + */ + localPath?: string; + + /** + * Path to the legacy `.env` fallback. Defaults to + * `/.env`. + * + * `.env` is read at the lowest precedence and exists for the + * duration of the migration; it will be removed in a future + * release. + */ + dotEnvPath?: string; + + /** + * If supplied, the loader fetches the YAML blob from the named + * Azure Key Vault secret and inserts it into the precedence + * chain between `defaults` and `local` (matches the locked + * design). Async-only — `loadConfigSync` ignores this option. + */ + keyVault?: KeyVaultOptions; + + /** + * If `true`, populate `process.env` with the merged result. + * Defaults to `true`. + */ + populateProcessEnv?: boolean; + + /** + * If `true`, throw when validation fails. If `false`, validation + * issues are logged but loading continues. Defaults to `true`. + */ + strict?: boolean; + + /** + * If `true`, track per-key provenance and return it on the result. + * Defaults to `false` (small extra cost). + */ + trackSources?: boolean; +} + +/** + * Result returned by `loadConfig`. Always includes the merged flat env + * map; provenance is included only when `trackSources` is enabled. + */ +export interface LoadConfigResult { + /** Final merged flat env map (also pushed into `process.env`). */ + env: FlatEnv; + + /** Per-key provenance, when `trackSources: true`. */ + sources?: SourceMap; +} diff --git a/ts/packages/config/test/cli.spec.ts b/ts/packages/config/test/cli.spec.ts new file mode 100644 index 0000000000..edd23a929e --- /dev/null +++ b/ts/packages/config/test/cli.spec.ts @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { runCli, type CliIO } from "../src/cli.js"; +import { REDACTED } from "../src/redact.js"; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "typeagent-config-cli-")); +} + +function makeIO(): CliIO & { out: string; err: string } { + let out = ""; + let err = ""; + return { + stdout: (t: string) => { + out += t; + }, + stderr: (t: string) => { + err += t; + }, + get out(): string { + return out; + }, + get err(): string { + return err; + }, + } as CliIO & { out: string; err: string }; +} + +describe("runCli", () => { + test("returns 0 when --help is requested explicitly", async () => { + const io = makeIO(); + const code = await runCli(["--help"], io); + expect(code).toBe(0); + expect(io.out).toContain("typeagent-config"); + }); + + test("returns 1 when no command is given", async () => { + const io = makeIO(); + const code = await runCli([], io); + expect(code).toBe(1); + }); + + test("rejects unknown commands", async () => { + const io = makeIO(); + const code = await runCli(["bogus"], io); + expect(code).toBe(1); + expect(io.err).toContain("Unknown command: bogus"); + }); +}); + +describe("import command", () => { + test("converts .env → YAML and verifies round-trip", async () => { + const dir = makeTempDir(); + try { + const envPath = path.join(dir, ".env"); + const outPath = path.join(dir, "config.local.yaml"); + fs.writeFileSync( + envPath, + "AZURE_OPENAI_API_KEY=sk-x\nOPENAI_MAX_CONCURRENCY=8\n", + ); + const io = makeIO(); + const code = await runCli( + ["import", envPath, "--out", outPath], + io, + ); + expect(code).toBe(0); + expect(io.out).toContain("Round-trip verified"); + expect(fs.existsSync(outPath)).toBe(true); + const yamlText = fs.readFileSync(outPath, "utf8"); + // The new typed importer emits a hierarchical azureOpenAI + // section; AZURE_OPENAI_API_KEY=sk-x becomes defaultAuth: sk-x. + expect(yamlText).toContain("azureOpenAI:"); + expect(yamlText).toContain("defaultAuth: sk-x"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test("missing input path yields exit code 1", async () => { + const io = makeIO(); + const code = await runCli(["import", "/no/such/file.env"], io); + expect(code).toBe(1); + expect(io.err).toContain("File not found"); + }); + + test("rejects unknown flags", async () => { + const io = makeIO(); + const code = await runCli(["import", "x", "--foo"], io); + expect(code).toBe(1); + expect(io.err).toContain("Unknown flag"); + }); +}); + +describe("show command", () => { + test("prints redacted merged config", async () => { + const dir = makeTempDir(); + try { + fs.writeFileSync( + path.join(dir, "config.defaults.yaml"), + "extra:\n AZURE_OPENAI_API_KEY: sk-secret\n OPENAI_MAX_CONCURRENCY: 8\n", + ); + const io = makeIO(); + // Override CWD-relative defaults via env? No — use direct + // workspaceRoot via a temporary chdir would couple us to + // global state. Instead, the CLI's show command reads from + // the inferred workspace root; we can't easily inject it. + // For the unit test, just exercise CLI shape. + const prev = process.cwd(); + process.chdir(dir); + try { + const code = await runCli(["show"], io); + expect(code).toBe(0); + // Defaults file at the workspace root we chdir'd into + // may or may not be picked up depending on workspace + // detection. The smoke test is that show exits 0 and + // any secret keys it does see are redacted. + if (io.out.includes("AZURE_OPENAI_API_KEY=")) { + // Either the value is the literal "identity" + // (Azure managed identity, never a secret) or + // it has been redacted. + const m = io.out.match(/AZURE_OPENAI_API_KEY=(\S+)/); + expect(m).not.toBeNull(); + expect([REDACTED, "identity"]).toContain(m![1]); + } + } finally { + process.chdir(prev); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test("rejects unknown flags", async () => { + const io = makeIO(); + const code = await runCli(["show", "--bogus"], io); + expect(code).toBe(1); + }); +}); + +describe("check command", () => { + test("requires a vault name", async () => { + const prev = process.env.TYPEAGENT_CONFIG_VAULT; + delete process.env.TYPEAGENT_CONFIG_VAULT; + try { + const io = makeIO(); + const code = await runCli(["check"], io); + expect(code).toBe(1); + expect(io.err).toContain("Vault name required"); + } finally { + if (prev !== undefined) { + process.env.TYPEAGENT_CONFIG_VAULT = prev; + } + } + }); + + test("rejects unknown flags", async () => { + const io = makeIO(); + const code = await runCli(["check", "--vault", "v", "--bogus"], io); + expect(code).toBe(1); + }); +}); diff --git a/ts/packages/config/test/flatten.spec.ts b/ts/packages/config/test/flatten.spec.ts new file mode 100644 index 0000000000..1fbb98b67f --- /dev/null +++ b/ts/packages/config/test/flatten.spec.ts @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { flatten, mergeFlat } from "../src/flatten.js"; + +describe("flatten", () => { + test("returns empty for null/undefined/empty input", () => { + expect(flatten(null)).toEqual({}); + expect(flatten(undefined)).toEqual({}); + expect(flatten({})).toEqual({}); + }); + + test("flattens nested maps with underscore-joined uppercase keys", () => { + const out = flatten({ + azure: { + openai: { + endpoint: "https://example.invalid/chat", + api_key: "identity", + }, + }, + }); + expect(out).toEqual({ + AZURE_OPENAI_ENDPOINT: "https://example.invalid/chat", + AZURE_OPENAI_API_KEY: "identity", + }); + }); + + test("preserves underscores in segment names", () => { + const out = flatten({ + azure_openai: { + endpoint_embedding: "https://example.invalid/embed", + }, + }); + expect(out).toEqual({ + AZURE_OPENAI_ENDPOINT_EMBEDDING: "https://example.invalid/embed", + }); + }); + + test("env: top-level passthrough leaves keys verbatim", () => { + const out = flatten({ + env: { + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS_PTU: "https://eastus-ptu", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS_PTU: "k1", + }, + }); + expect(out).toEqual({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS_PTU: "https://eastus-ptu", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS_PTU: "k1", + }); + }); + + test("extra: top-level passthrough leaves keys verbatim", () => { + const out = flatten({ + extra: { + SPOTIFY_APP_CLI: "spot-id", + MSGRAPH_APP_CLIENTID: "graph-id", + }, + }); + expect(out).toEqual({ + SPOTIFY_APP_CLI: "spot-id", + MSGRAPH_APP_CLIENTID: "graph-id", + }); + }); + + test("structured form produces same flat keys as env passthrough", () => { + const structured = flatten({ + azure: { + openai: { + endpoint: "https://example.invalid/chat", + api_key: "identity", + response_format: true, + max_concurrency: 4, + max_timeout: 120000, + }, + }, + }); + const passthrough = flatten({ + env: { + AZURE_OPENAI_ENDPOINT: "https://example.invalid/chat", + AZURE_OPENAI_API_KEY: "identity", + AZURE_OPENAI_RESPONSE_FORMAT: "1", + AZURE_OPENAI_MAX_CONCURRENCY: "4", + AZURE_OPENAI_MAX_TIMEOUT: "120000", + }, + }); + expect(structured).toEqual(passthrough); + }); + + test("booleans: true => '1', false => omitted", () => { + const out = flatten({ + azure: { + openai: { + response_format: true, + enable_logging: false, + }, + }, + }); + expect(out).toEqual({ AZURE_OPENAI_RESPONSE_FORMAT: "1" }); + }); + + test("numbers are stringified", () => { + const out = flatten({ + azure: { openai: { max_timeout: 120000, max_concurrency: 4 } }, + }); + expect(out).toEqual({ + AZURE_OPENAI_MAX_TIMEOUT: "120000", + AZURE_OPENAI_MAX_CONCURRENCY: "4", + }); + }); + + test("null and undefined leaves are dropped", () => { + const out = flatten({ + azure: { + openai: { + endpoint: "https://x", + api_key: null, + other: null, + }, + }, + }); + expect(out).toEqual({ AZURE_OPENAI_ENDPOINT: "https://x" }); + }); + + test("non-finite numbers are dropped", () => { + const out = flatten({ + tuning: { ratio: Number.NaN, big: Number.POSITIVE_INFINITY }, + }); + expect(out).toEqual({}); + }); + + test("arrays throw with a descriptive error", () => { + expect(() => + flatten({ + azure: { + openai: { + deployments: [ + { name: "gpt-4o", endpoint: "https://x" }, + ] as unknown as null, + }, + }, + }), + ).toThrow(/Arrays are not supported/); + }); + + test("preserves byte-identical keys for endpointPool suffix convention", () => { + // This is the contract that lets `discoverEndpointPool` keep + // working unchanged: the YAML must produce exactly the same + // flat keys the existing tests rely on. + const out = flatten({ + env: { + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS_PTU: "https://eastus-ptu", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS_PTU: "k1", + AZURE_OPENAI_ENDPOINT_GPT_4_O_SWEDEN: "https://sweden", + AZURE_OPENAI_API_KEY_GPT_4_O_SWEDEN: "k2", + AZURE_OPENAI_ENDPOINT_GPT_4_O_WESTUS: "https://westus", + AZURE_OPENAI_API_KEY_GPT_4_O_WESTUS: "k3", + }, + }); + expect(Object.keys(out).sort()).toEqual( + [ + "AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS_PTU", + "AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS_PTU", + "AZURE_OPENAI_ENDPOINT_GPT_4_O_SWEDEN", + "AZURE_OPENAI_API_KEY_GPT_4_O_SWEDEN", + "AZURE_OPENAI_ENDPOINT_GPT_4_O_WESTUS", + "AZURE_OPENAI_API_KEY_GPT_4_O_WESTUS", + ].sort(), + ); + }); +}); + +describe("mergeFlat", () => { + test("later wins", () => { + const a = { K1: "a", K2: "a" }; + const b = { K2: "b", K3: "b" }; + const c = { K3: "c" }; + expect(mergeFlat(a, b, c)).toEqual({ + K1: "a", + K2: "b", + K3: "c", + }); + }); + + test("does not mutate inputs", () => { + const a = { K1: "a" }; + const b = { K1: "b" }; + mergeFlat(a, b); + expect(a).toEqual({ K1: "a" }); + expect(b).toEqual({ K1: "b" }); + }); + + test("zero-arg returns empty object", () => { + expect(mergeFlat()).toEqual({}); + }); +}); diff --git a/ts/packages/config/test/import.spec.ts b/ts/packages/config/test/import.spec.ts new file mode 100644 index 0000000000..14a1af0fa1 --- /dev/null +++ b/ts/packages/config/test/import.spec.ts @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + flatEnvToConfigTree, + importDotEnv, + parseDotEnvText, + writeConfigYamlFile, +} from "../src/import.js"; +import { flatten } from "../src/flatten.js"; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "typeagent-config-import-")); +} + +describe("parseDotEnvText", () => { + test("parses simple key=value pairs", () => { + const flat = parseDotEnvText( + ["A=1", "B=hello", "# comment", "C=value with spaces"].join("\n"), + ); + expect(flat).toEqual({ + A: "1", + B: "hello", + C: "value with spaces", + }); + }); + + test("strips quotes per dotenv conventions", () => { + const flat = parseDotEnvText(`A="quoted"\nB='single'\n`); + expect(flat.A).toBe("quoted"); + expect(flat.B).toBe("single"); + }); +}); + +describe("flatEnvToConfigTree", () => { + test("emits typed azureOpenAI section for recognized keys", () => { + const tree = flatEnvToConfigTree({ + AZURE_OPENAI_ENDPOINT: "https://x", + BING_API_KEY: "xyz", + }); + expect(tree.azureOpenAI).toBeDefined(); + const azure = tree.azureOpenAI as Record; + expect((azure.defaultChat as Record).endpoint).toBe( + "https://x", + ); + expect(tree.extra).toEqual({ BING_API_KEY: "xyz" }); + }); + + test("returns near-empty tree for empty input (defaults only)", () => { + // buildConfig always produces an azureOpenAI section with defaults. + const tree = flatEnvToConfigTree({}); + expect(Object.keys(tree)).toEqual(["azureOpenAI"]); + }); + + test("output round-trips through flatten", () => { + const flat = { + AZURE_OPENAI_API_KEY: "secret", + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS_PTU: "https://eastus", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS_PTU: "secret", + OPENAI_API_KEY: "sk-x", + OPENAI_MAX_CONCURRENCY: "32", + }; + const tree = flatEnvToConfigTree(flat); + const round = flatten(tree); + // Every input key survives with the same value. + for (const [k, v] of Object.entries(flat)) { + expect(round[k]).toBe(v); + } + }); +}); + +describe("importDotEnv", () => { + test("end-to-end: file → tree → verified round-trip", () => { + const dir = makeTempDir(); + try { + const envPath = path.join(dir, ".env"); + fs.writeFileSync( + envPath, + [ + "# leading comment", + "AZURE_OPENAI_API_KEY=sk-test", + "AZURE_OPENAI_ENDPOINT=https://kv.example", + "OPENAI_MAX_CONCURRENCY=8", + "BING_API_KEY=identity", + "", + ].join("\n"), + ); + const result = importDotEnv(envPath); + expect(result.counts.total).toBe(4); + // structured = number of typed top-level sections + identity + // entries; in this input azureOpenAI is the only typed section + // (no identity values). + expect(result.counts.structured).toBeGreaterThanOrEqual(1); + expect(result.intentionalRewrites).toEqual([]); + // Round-trip is the contract. + expect(result.roundTrip.AZURE_OPENAI_API_KEY).toBe("sk-test"); + expect(result.roundTrip.AZURE_OPENAI_ENDPOINT).toBe( + "https://kv.example", + ); + expect(result.roundTrip.BING_API_KEY).toBe("identity"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test("throws on missing file", () => { + expect(() => importDotEnv("/no/such/.env")).toThrow(); + }); +}); + +describe("writeConfigYamlFile", () => { + test("writes sorted YAML and creates parent directories", () => { + const dir = makeTempDir(); + try { + const target = path.join(dir, "nested", "out.yaml"); + writeConfigYamlFile( + target, + { extra: { B: "2", A: "1" } }, + "# header line\n", + ); + const text = fs.readFileSync(target, "utf8"); + expect(text.startsWith("# header line\n")).toBe(true); + // sortKeys: true means A appears before B inside extras. + expect(text.indexOf("A:")).toBeLessThan(text.indexOf("B:")); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/ts/packages/config/test/keyVault.spec.ts b/ts/packages/config/test/keyVault.spec.ts new file mode 100644 index 0000000000..327383de45 --- /dev/null +++ b/ts/packages/config/test/keyVault.spec.ts @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { fetchKeyVaultConfig } from "../src/keyVault.js"; +import type { KeyVaultFetcher } from "../src/keyVault.js"; + +/** + * Build a stub fetcher that returns the given map of + * `secret-name -> raw YAML string`. Unknown secrets resolve to null. + */ +function stubFetcher(secrets: Record): KeyVaultFetcher { + return async (_vault, name) => { + if (!(name in secrets)) return null; + return secrets[name]; + }; +} + +describe("fetchKeyVaultConfig", () => { + test("parses a YAML blob into a ConfigTree", async () => { + const fetcher = stubFetcher({ + "typeagent-config": [ + "azure:", + " openai:", + " endpoint: https://kv.example/chat", + " api_key: from-vault", + ].join("\n"), + }); + const tree = await fetchKeyVaultConfig({ + vaultName: "aisystems", + fetcher, + }); + expect(tree).toEqual({ + azure: { + openai: { + endpoint: "https://kv.example/chat", + api_key: "from-vault", + }, + }, + }); + }); + + test("uses DEFAULT_SECRET_NAME when secretName not specified", async () => { + const seen: string[] = []; + const fetcher: KeyVaultFetcher = async (_vault, name) => { + seen.push(name); + return "openai:\n api_key: x\n"; + }; + await fetchKeyVaultConfig({ vaultName: "aisystems", fetcher }); + expect(seen).toEqual(["typeagent-config"]); + }); + + test("honors explicit secretName", async () => { + const seen: string[] = []; + const fetcher: KeyVaultFetcher = async (_vault, name) => { + seen.push(name); + return "openai:\n api_key: x\n"; + }; + await fetchKeyVaultConfig({ + vaultName: "aisystems", + secretName: "ci-overrides", + fetcher, + }); + expect(seen).toEqual(["ci-overrides"]); + }); + + test("returns null when secret does not exist", async () => { + const fetcher = stubFetcher({}); + const tree = await fetchKeyVaultConfig({ + vaultName: "aisystems", + fetcher, + }); + expect(tree).toBeNull(); + }); + + test("returns null on empty secret value", async () => { + const fetcher = stubFetcher({ "typeagent-config": "" }); + const tree = await fetchKeyVaultConfig({ + vaultName: "aisystems", + fetcher, + }); + expect(tree).toBeNull(); + }); + + test("returns null on fetch error when failOnError is false", async () => { + const fetcher: KeyVaultFetcher = async () => { + throw new Error("network down"); + }; + const tree = await fetchKeyVaultConfig({ + vaultName: "aisystems", + fetcher, + }); + expect(tree).toBeNull(); + }); + + test("rethrows on fetch error when failOnError is true", async () => { + const fetcher: KeyVaultFetcher = async () => { + throw new Error("network down"); + }; + await expect( + fetchKeyVaultConfig({ + vaultName: "aisystems", + fetcher, + failOnError: true, + }), + ).rejects.toThrow(/network down/); + }); + + test("returns null on top-level YAML array (not a map)", async () => { + const fetcher = stubFetcher({ + "typeagent-config": "- one\n- two\n", + }); + const tree = await fetchKeyVaultConfig({ + vaultName: "aisystems", + fetcher, + }); + expect(tree).toBeNull(); + }); + + test("validation error throws when failOnError is true", async () => { + const fetcher = stubFetcher({ + "typeagent-config": "deployments:\n - one\n - two\n", + }); + await expect( + fetchKeyVaultConfig({ + vaultName: "aisystems", + fetcher, + failOnError: true, + }), + ).rejects.toThrow(/Invalid TypeAgent config/); + }); + + test("rejects oversized blob", async () => { + const big = "padding: " + "x".repeat(26 * 1024) + "\n"; + const fetcher = stubFetcher({ "typeagent-config": big }); + await expect( + fetchKeyVaultConfig({ + vaultName: "aisystems", + fetcher, + failOnError: true, + }), + ).rejects.toThrow(/exceeds the .* limit/); + }); + + test("test-isolation guard blocks live fetch under Jest", async () => { + // No fetcher supplied → would hit the live SDK. Under Jest this + // is blocked and returns null. + const tree = await fetchKeyVaultConfig({ + vaultName: "definitely-not-a-real-vault", + }); + expect(tree).toBeNull(); + }); + + test("test-isolation guard does NOT block when fetcher is provided", async () => { + // Stub fetcher means the call never reaches Azure, so the + // guard does not fire. + const fetcher = stubFetcher({ + "typeagent-config": "openai:\n api_key: ok\n", + }); + const tree = await fetchKeyVaultConfig({ + vaultName: "any", + fetcher, + }); + expect(tree).toEqual({ openai: { api_key: "ok" } }); + }); + + test("test-isolation guard can be bypassed via env opt-in", async () => { + // The opt-in is honored even though no fetcher is supplied; we + // can't make a real call here, so we expect the SDK call to + // fail and (since failOnError is false) yield null. The point + // is that the guard's distinct "refusing live call" debug + // message is NOT what's blocking us. + const prev = process.env.TYPEAGENT_ALLOW_KEYVAULT_IN_TESTS; + process.env.TYPEAGENT_ALLOW_KEYVAULT_IN_TESTS = "1"; + try { + const tree = await fetchKeyVaultConfig({ + vaultName: "definitely-not-a-real-vault-12345", + }); + // Live call attempted but failed; failOnError defaults to + // false, so we get null. + expect(tree).toBeNull(); + } finally { + if (prev === undefined) { + delete process.env.TYPEAGENT_ALLOW_KEYVAULT_IN_TESTS; + } else { + process.env.TYPEAGENT_ALLOW_KEYVAULT_IN_TESTS = prev; + } + } + }, 30000); +}); diff --git a/ts/packages/config/test/loader.spec.ts b/ts/packages/config/test/loader.spec.ts new file mode 100644 index 0000000000..5df31ab5a3 --- /dev/null +++ b/ts/packages/config/test/loader.spec.ts @@ -0,0 +1,263 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { loadConfigSync, loadConfig } from "../src/loader.js"; + +function makeTempWorkspace(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "typeagent-config-test-")); +} + +function cleanProcessEnv(keys: string[]): void { + for (const k of keys) { + delete process.env[k]; + } +} + +describe("loadConfigSync", () => { + const trackedKeys = [ + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_MAX_CONCURRENCY", + "OPENAI_API_KEY", + "BING_API_KEY", + "TYPEAGENT_TEST_KEY", + ]; + + afterEach(() => cleanProcessEnv(trackedKeys)); + + test("returns empty result when no files exist", () => { + const root = makeTempWorkspace(); + try { + const result = loadConfigSync({ + workspaceRoot: root, + populateProcessEnv: false, + }); + expect(result.env).toEqual({}); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("loads defaults YAML and populates process.env", () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + [ + "azure:", + " openai:", + " max_concurrency: 4", + " response_format: true", + ].join("\n"), + ); + cleanProcessEnv(trackedKeys); + const result = loadConfigSync({ workspaceRoot: root }); + expect(result.env.AZURE_OPENAI_MAX_CONCURRENCY).toBe("4"); + expect(result.env.AZURE_OPENAI_RESPONSE_FORMAT).toBe("1"); + expect(process.env.AZURE_OPENAI_MAX_CONCURRENCY).toBe("4"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("local YAML overrides defaults", () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + ["azure:", " openai:", " max_concurrency: 4"].join("\n"), + ); + fs.writeFileSync( + path.join(root, "config.local.yaml"), + ["azure:", " openai:", " max_concurrency: 16"].join("\n"), + ); + cleanProcessEnv(trackedKeys); + const result = loadConfigSync({ + workspaceRoot: root, + populateProcessEnv: false, + }); + expect(result.env.AZURE_OPENAI_MAX_CONCURRENCY).toBe("16"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(".env loads as lowest-precedence fallback", () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, ".env"), + [ + "BING_API_KEY=from-dotenv", + "AZURE_OPENAI_API_KEY=from-dotenv", + ].join("\n"), + ); + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + ["env:", " AZURE_OPENAI_API_KEY: from-yaml"].join("\n"), + ); + cleanProcessEnv(trackedKeys); + const result = loadConfigSync({ + workspaceRoot: root, + populateProcessEnv: false, + }); + // Defaults wins over .env. + expect(result.env.AZURE_OPENAI_API_KEY).toBe("from-yaml"); + // .env-only key still flows through. + expect(result.env.BING_API_KEY).toBe("from-dotenv"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("local YAML overrides Key Vault would-be values (and .env)", () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync(path.join(root, ".env"), "BING_API_KEY=dotenv\n"); + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "bing:\n api_key: defaults\n", + ); + fs.writeFileSync( + path.join(root, "config.local.yaml"), + "bing:\n api_key: local\n", + ); + cleanProcessEnv(trackedKeys); + const result = loadConfigSync({ + workspaceRoot: root, + populateProcessEnv: false, + }); + expect(result.env.BING_API_KEY).toBe("local"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("preserves existing process.env (does not clobber overrides)", () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "openai:\n api_key: from-yaml\n", + ); + cleanProcessEnv(trackedKeys); + process.env.OPENAI_API_KEY = "from-shell"; + loadConfigSync({ workspaceRoot: root }); + expect(process.env.OPENAI_API_KEY).toBe("from-shell"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("idempotent: calling twice does not double-apply or change result", () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "openai:\n api_key: stable\n", + ); + cleanProcessEnv(trackedKeys); + const first = loadConfigSync({ workspaceRoot: root }); + const second = loadConfigSync({ workspaceRoot: root }); + expect(first.env).toEqual(second.env); + expect(process.env.OPENAI_API_KEY).toBe("stable"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("trackSources records origin per key", () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, ".env"), + "TYPEAGENT_TEST_KEY=from-dotenv\n", + ); + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "openai:\n api_key: from-defaults\n", + ); + fs.writeFileSync( + path.join(root, "config.local.yaml"), + "openai:\n api_key: from-local\n", + ); + cleanProcessEnv(trackedKeys); + const result = loadConfigSync({ + workspaceRoot: root, + populateProcessEnv: false, + trackSources: true, + }); + expect(result.sources).toBeDefined(); + expect(result.sources!.OPENAI_API_KEY).toBe("local"); + expect(result.sources!.TYPEAGENT_TEST_KEY).toBe("dotenv"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("strict: invalid YAML throws", () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "deployments:\n - one\n - two\n", + ); + cleanProcessEnv(trackedKeys); + expect(() => + loadConfigSync({ + workspaceRoot: root, + populateProcessEnv: false, + }), + ).toThrow(/Invalid TypeAgent config/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("non-strict: invalid YAML is logged and skipped", () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "deployments:\n - one\n", + ); + fs.writeFileSync( + path.join(root, "config.local.yaml"), + "openai:\n api_key: ok\n", + ); + cleanProcessEnv(trackedKeys); + const result = loadConfigSync({ + workspaceRoot: root, + populateProcessEnv: false, + strict: false, + }); + expect(result.env.OPENAI_API_KEY).toBe("ok"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("loadConfig (async)", () => { + test("returns the same shape as loadConfigSync in Phase 1", async () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "openai:\n api_key: hello\n", + ); + delete process.env.OPENAI_API_KEY; + const result = await loadConfig({ + workspaceRoot: root, + populateProcessEnv: false, + }); + expect(result.env.OPENAI_API_KEY).toBe("hello"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + delete process.env.OPENAI_API_KEY; + } + }); +}); diff --git a/ts/packages/config/test/loaderKeyVault.spec.ts b/ts/packages/config/test/loaderKeyVault.spec.ts new file mode 100644 index 0000000000..370b18e29c --- /dev/null +++ b/ts/packages/config/test/loaderKeyVault.spec.ts @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { loadConfig } from "../src/loader.js"; +import type { KeyVaultFetcher } from "../src/keyVault.js"; + +function makeTempWorkspace(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "typeagent-config-kv-")); +} + +function cleanProcessEnv(keys: string[]): void { + for (const k of keys) { + delete process.env[k]; + } +} + +function stubFetcher(yamlText: string | null): KeyVaultFetcher { + return async () => yamlText; +} + +describe("loadConfig (with Key Vault layer)", () => { + const tracked = [ + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_MAX_CONCURRENCY", + "BING_API_KEY", + "OPENAI_API_KEY", + ]; + + afterEach(() => cleanProcessEnv(tracked)); + + test("Key Vault layer overrides defaults", async () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "azure:\n openai:\n max_concurrency: 4\n", + ); + cleanProcessEnv(tracked); + const result = await loadConfig({ + workspaceRoot: root, + populateProcessEnv: false, + keyVault: { + vaultName: "aisystems", + fetcher: stubFetcher( + "azure:\n openai:\n max_concurrency: 32\n", + ), + }, + }); + expect(result.env.AZURE_OPENAI_MAX_CONCURRENCY).toBe("32"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("local YAML overrides Key Vault", async () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.local.yaml"), + "openai:\n api_key: from-local\n", + ); + cleanProcessEnv(tracked); + const result = await loadConfig({ + workspaceRoot: root, + populateProcessEnv: false, + keyVault: { + vaultName: "aisystems", + fetcher: stubFetcher("openai:\n api_key: from-vault\n"), + }, + }); + expect(result.env.OPENAI_API_KEY).toBe("from-local"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("Key Vault overrides .env (which sits below defaults)", async () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, ".env"), + "BING_API_KEY=from-dotenv\n", + ); + cleanProcessEnv(tracked); + const result = await loadConfig({ + workspaceRoot: root, + populateProcessEnv: false, + keyVault: { + vaultName: "aisystems", + fetcher: stubFetcher("bing:\n api_key: from-vault\n"), + }, + }); + expect(result.env.BING_API_KEY).toBe("from-vault"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("missing Key Vault secret leaves defaults intact", async () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "azure:\n openai:\n max_concurrency: 4\n", + ); + cleanProcessEnv(tracked); + const result = await loadConfig({ + workspaceRoot: root, + populateProcessEnv: false, + keyVault: { + vaultName: "aisystems", + fetcher: stubFetcher(null), + }, + }); + expect(result.env.AZURE_OPENAI_MAX_CONCURRENCY).toBe("4"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("Key Vault fetch error in non-strict mode falls through", async () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "openai:\n api_key: ok\n", + ); + cleanProcessEnv(tracked); + const failingFetcher: KeyVaultFetcher = async () => { + throw new Error("simulated failure"); + }; + const result = await loadConfig({ + workspaceRoot: root, + populateProcessEnv: false, + strict: false, + keyVault: { + vaultName: "aisystems", + fetcher: failingFetcher, + }, + }); + expect(result.env.OPENAI_API_KEY).toBe("ok"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("Key Vault fetch error in strict mode propagates", async () => { + const root = makeTempWorkspace(); + try { + cleanProcessEnv(tracked); + const failingFetcher: KeyVaultFetcher = async () => { + throw new Error("simulated failure"); + }; + await expect( + loadConfig({ + workspaceRoot: root, + populateProcessEnv: false, + strict: true, + keyVault: { + vaultName: "aisystems", + fetcher: failingFetcher, + }, + }), + ).rejects.toThrow(/simulated failure/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("source tracking attributes Key Vault keys correctly", async () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "azure:\n openai:\n max_concurrency: 4\n", + ); + cleanProcessEnv(tracked); + const result = await loadConfig({ + workspaceRoot: root, + populateProcessEnv: false, + trackSources: true, + keyVault: { + vaultName: "aisystems", + fetcher: stubFetcher("openai:\n api_key: from-vault\n"), + }, + }); + expect(result.sources).toBeDefined(); + expect(result.sources!.AZURE_OPENAI_MAX_CONCURRENCY).toBe( + "defaults", + ); + expect(result.sources!.OPENAI_API_KEY).toBe("key-vault"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("loadConfig without keyVault option behaves like loadConfigSync", async () => { + const root = makeTempWorkspace(); + try { + fs.writeFileSync( + path.join(root, "config.defaults.yaml"), + "openai:\n api_key: x\n", + ); + cleanProcessEnv(tracked); + const result = await loadConfig({ + workspaceRoot: root, + populateProcessEnv: false, + }); + expect(result.env.OPENAI_API_KEY).toBe("x"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/ts/packages/config/test/redact.spec.ts b/ts/packages/config/test/redact.spec.ts new file mode 100644 index 0000000000..89574a891d --- /dev/null +++ b/ts/packages/config/test/redact.spec.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + redactFlat, + redactTree, + shouldRedact, + REDACTED, +} from "../src/redact.js"; + +describe("shouldRedact", () => { + test.each([ + ["azure.openai.api_key", "secret-value", true], + ["AZURE_OPENAI_API_KEY", "secret-value", true], + ["bing.api_key", "abc", true], + ["my.password", "pw", true], + ["auth.token", "xyz", true], + ["DB_CREDENTIAL", "x", true], + ])("flags %s as secret", (key, val, expected) => { + expect(shouldRedact(key, val)).toBe(expected); + }); + + test.each([ + ["azure.openai.endpoint", "https://x"], + ["max_concurrency", "32"], + ["AZURE_OPENAI_MAX_TIMEOUT", "120000"], + ])("does not flag non-secret key %s", (key, val) => { + expect(shouldRedact(key, val)).toBe(false); + }); + + test("does not flag identity sentinel", () => { + expect(shouldRedact("AZURE_OPENAI_API_KEY", "identity")).toBe(false); + }); + + test("does not flag empty string", () => { + expect(shouldRedact("AZURE_OPENAI_API_KEY", "")).toBe(false); + }); + + test("does not flag non-string values", () => { + expect(shouldRedact("api_key", 42)).toBe(false); + expect(shouldRedact("api_key", true)).toBe(false); + }); +}); + +describe("redactFlat", () => { + test("masks sensitive keys, leaves others intact", () => { + const flat = { + AZURE_OPENAI_ENDPOINT: "https://x", + AZURE_OPENAI_API_KEY: "sk-test", + OPENAI_MAX_CONCURRENCY: "32", + BING_API_KEY: "abc", + }; + const out = redactFlat(flat); + expect(out.AZURE_OPENAI_ENDPOINT).toBe("https://x"); + expect(out.OPENAI_MAX_CONCURRENCY).toBe("32"); + expect(out.AZURE_OPENAI_API_KEY).toBe(REDACTED); + expect(out.BING_API_KEY).toBe(REDACTED); + }); + + test("preserves identity sentinel", () => { + const out = redactFlat({ AZURE_OPENAI_API_KEY: "identity" }); + expect(out.AZURE_OPENAI_API_KEY).toBe("identity"); + }); +}); + +describe("redactTree", () => { + test("recursively redacts nested objects", () => { + const tree = { + azure: { + openai: { + endpoint: "https://x", + api_key: "sk-test", + }, + }, + max_concurrency: 32, + extras: { + BING_API_KEY: "secret", + }, + }; + const out = redactTree(tree) as typeof tree; + expect(out.azure.openai.endpoint).toBe("https://x"); + expect(out.azure.openai.api_key).toBe(REDACTED); + expect(out.max_concurrency).toBe(32); + expect(out.extras.BING_API_KEY).toBe(REDACTED); + }); + + test("leaves the input unchanged (no mutation)", () => { + const tree = { azure: { openai: { api_key: "sk-test" } } }; + const before = JSON.stringify(tree); + redactTree(tree); + expect(JSON.stringify(tree)).toBe(before); + }); +}); diff --git a/ts/packages/config/test/runtime.build.spec.ts b/ts/packages/config/test/runtime.build.spec.ts new file mode 100644 index 0000000000..c5533e0b01 --- /dev/null +++ b/ts/packages/config/test/runtime.build.spec.ts @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + authModeFromString, + buildConfig, + IDENTITY, + parseSuffix, + type FlatEnv, +} from "../src/index.js"; + +describe("parseSuffix", () => { + test("strips trailing _PTU mode marker", () => { + const r = parseSuffix("GPT_4_O_EASTUS_PTU"); + expect(r.deployment).toBe("gpt_4_o"); + expect(r.region).toBe("eastus"); + expect(r.mode).toBe("PTU"); + }); + + test("longest-match region wins (canadacentral, not central)", () => { + const r = parseSuffix("GPT_5_2_CANADACENTRAL"); + expect(r.deployment).toBe("gpt_5_2"); + expect(r.region).toBe("canadacentral"); + expect(r.mode).toBe("PAYG"); + }); + + test("multi-word deployment + region", () => { + const r = parseSuffix("EMBEDDING_3_LARGE_SWEDENCENTRAL"); + expect(r.deployment).toBe("embedding_3_large"); + expect(r.region).toBe("swedencentral"); + }); + + test("no recognizable region → undefined region", () => { + const r = parseSuffix("GPT_5"); + expect(r.deployment).toBe("gpt_5"); + expect(r.region).toBeUndefined(); + }); +}); + +describe("authModeFromString", () => { + test("'identity' (any case) → identity mode", () => { + expect(authModeFromString("identity")).toEqual(IDENTITY); + expect(authModeFromString("Identity")).toEqual(IDENTITY); + }); + test("undefined / empty → identity mode", () => { + expect(authModeFromString(undefined)).toEqual(IDENTITY); + expect(authModeFromString("")).toEqual(IDENTITY); + }); + test("any other string → key mode", () => { + expect(authModeFromString("sk-abc")).toEqual({ + kind: "key", + value: "sk-abc", + }); + }); +}); + +describe("buildConfig: Azure OpenAI defaults", () => { + test("default tuning knobs come from constants when env is empty", () => { + const config = buildConfig({}); + expect(config.azureOpenAI.maxConcurrency).toBe(4); + expect(config.azureOpenAI.maxTimeoutMs).toBe(60_000); + expect(config.azureOpenAI.maxRetryAttempts).toBe(3); + expect(config.azureOpenAI.responseFormat).toBe(false); + expect(config.azureOpenAI.defaultAuth).toEqual(IDENTITY); + expect(config.azureOpenAI.deployments.size).toBe(0); + }); + + test("inherits identity default when AZURE_OPENAI_API_KEY=identity", () => { + const flat: FlatEnv = { + AZURE_OPENAI_API_KEY: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://eastus", + }; + const config = buildConfig(flat); + const dep = config.azureOpenAI.deployments.get("gpt_4_o")!; + expect(dep).toBeDefined(); + const ep = dep.endpoints.find((e) => e.region === "eastus")!; + expect(ep.endpoint).toBe("https://eastus"); + expect(ep.auth).toEqual(IDENTITY); + expect(ep.region).toBe("eastus"); + expect(ep.mode).toBe("PAYG"); + }); + + test("explicit per-endpoint key overrides default auth", () => { + const flat: FlatEnv = { + AZURE_OPENAI_API_KEY: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://eastus", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "sk-explicit", + }; + const config = buildConfig(flat); + const ep = config.azureOpenAI.deployments + .get("gpt_4_o")! + .endpoints.find((e) => e.region === "eastus")!; + expect(ep.auth).toEqual({ kind: "key", value: "sk-explicit" }); + }); + + test("PTU suffix yields PTU mode and priority 1", () => { + const flat: FlatEnv = { + AZURE_OPENAI_ENDPOINT_GPT_4_O_SWEDENCENTRAL_PTU: + "https://sweden-ptu", + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://eastus", + }; + const config = buildConfig(flat); + const dep = config.azureOpenAI.deployments.get("gpt_4_o")!; + expect(dep.endpoints.length).toBe(2); + // sorted by priority: PTU (1) before PAYG (2) + expect(dep.endpoints[0].mode).toBe("PTU"); + expect(dep.endpoints[0].region).toBe("swedencentral"); + expect(dep.endpoints[0].priority).toBe(1); + expect(dep.endpoints[1].mode).toBe("PAYG"); + expect(dep.endpoints[1].priority).toBe(2); + }); + + test("multiple deployments with multiple regions are grouped correctly", () => { + const flat: FlatEnv = { + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://4o-east", + AZURE_OPENAI_ENDPOINT_GPT_4_O_WESTUS: "https://4o-west", + AZURE_OPENAI_ENDPOINT_EMBEDDING_3_LARGE_EASTUS: "https://emb-east", + AZURE_OPENAI_ENDPOINT_EMBEDDING_3_LARGE_SWEDENCENTRAL: + "https://emb-sweden", + }; + const config = buildConfig(flat); + expect([...config.azureOpenAI.deployments.keys()].sort()).toEqual([ + "embedding_3_large", + "gpt_4_o", + ]); + expect( + config.azureOpenAI.deployments.get("embedding_3_large")!.endpoints + .length, + ).toBe(2); + }); + + test("bare AZURE_OPENAI_ENDPOINT_EMBEDDING goes to defaultEmbedding", () => { + const flat: FlatEnv = { + AZURE_OPENAI_ENDPOINT_EMBEDDING: "https://ada-bare", + AZURE_OPENAI_API_KEY_EMBEDDING: "identity", + AZURE_OPENAI_ENDPOINT_EMBEDDING_EASTUS: "https://ada-east", + }; + const config = buildConfig(flat); + expect(config.azureOpenAI.defaultEmbedding?.endpoint).toBe( + "https://ada-bare", + ); + // Suffixed variant still becomes a deployment + expect( + config.azureOpenAI.deployments + .get("embedding") + ?.endpoints.find((e) => e.region === "eastus")?.endpoint, + ).toBe("https://ada-east"); + }); + + test("unrecognized AZURE_OPENAI_*_NOREGION endpoints land in extra", () => { + const flat: FlatEnv = { + // No recognizable region in the suffix + AZURE_OPENAI_ENDPOINT_GPT_5: "https://gpt5-bare", + }; + const config = buildConfig(flat); + expect(config.azureOpenAI.deployments.size).toBe(0); + expect(config.extra.get("AZURE_OPENAI_ENDPOINT_GPT_5")).toBe( + "https://gpt5-bare", + ); + }); +}); + +describe("buildConfig: other sections", () => { + test("speech section requires region", () => { + const flat: FlatEnv = { + SPEECH_SDK_KEY: "identity", + SPEECH_SDK_REGION: "westus", + SPEECH_SDK_ENDPOINT: "https://speech", + }; + const config = buildConfig(flat); + expect(config.speech?.region).toBe("westus"); + expect(config.speech?.auth).toEqual(IDENTITY); + expect(config.speech?.endpoint).toBe("https://speech"); + }); + + test("speech with unknown region falls through to extra", () => { + const flat: FlatEnv = { + SPEECH_SDK_KEY: "identity", + SPEECH_SDK_REGION: "neverland", + }; + const config = buildConfig(flat); + expect(config.speech).toBeUndefined(); + expect(config.extra.get("SPEECH_SDK_REGION")).toBe("neverland"); + }); + + test("ms graph with all three required fields", () => { + const flat: FlatEnv = { + MSGRAPH_APP_CLIENTID: "cid", + MSGRAPH_APP_CLIENTSECRET: "secret", + MSGRAPH_APP_TENANTID: "tid", + }; + const config = buildConfig(flat); + expect(config.msGraph?.clientId).toBe("cid"); + expect(config.msGraph?.clientSecret).toBe("secret"); + expect(config.msGraph?.tenantId).toBe("tid"); + }); + + test("storage azure + cosmos", () => { + const flat: FlatEnv = { + AZURE_STORAGE_ACCOUNT: "acc", + AZURE_STORAGE_CONTAINER: "cont", + COSMOSDB_CONNECTION_STRING: "cs", + }; + const config = buildConfig(flat); + expect(config.storage.azure).toEqual({ + account: "acc", + container: "cont", + }); + expect(config.storage.database?.cosmosDbConnectionString).toBe("cs"); + }); + + test("vault shared name", () => { + const config = buildConfig({ TYPEAGENT_SHAREDVAULT: "aisystems" }); + expect(config.vault?.shared).toBe("aisystems"); + }); +}); + +describe("buildConfig: extras passthrough", () => { + test("unknown keys land in extra", () => { + const flat: FlatEnv = { + COMPLETELY_RANDOM_KEY: "value", + ANOTHER_THING: "42", + }; + const config = buildConfig(flat); + expect(config.extra.get("COMPLETELY_RANDOM_KEY")).toBe("value"); + expect(config.extra.get("ANOTHER_THING")).toBe("42"); + }); + + test("input map is not mutated", () => { + const flat: FlatEnv = { AZURE_OPENAI_API_KEY: "identity" }; + const before = { ...flat }; + buildConfig(flat); + expect(flat).toEqual(before); + }); +}); diff --git a/ts/packages/config/test/runtime.shim.spec.ts b/ts/packages/config/test/runtime.shim.spec.ts new file mode 100644 index 0000000000..8efeb4619c --- /dev/null +++ b/ts/packages/config/test/runtime.shim.spec.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + applyToProcessEnv, + buildConfig, + configToEnv, + type FlatEnv, +} from "../src/index.js"; + +describe("configToEnv: shim projection", () => { + test("round-trips Azure OpenAI deployment endpoints", () => { + const flat: FlatEnv = { + AZURE_OPENAI_API_KEY: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: "https://4o-east", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + AZURE_OPENAI_ENDPOINT_GPT_4_O_SWEDENCENTRAL_PTU: + "https://4o-sw-ptu", + AZURE_OPENAI_API_KEY_GPT_4_O_SWEDENCENTRAL_PTU: "identity", + }; + const projected = configToEnv(buildConfig(flat)); + for (const [k, v] of Object.entries(flat)) { + expect(projected[k]).toBe(v); + } + }); + + test("emits tuning knobs as strings, response_format as 1/0", () => { + const config = buildConfig({ + AZURE_OPENAI_RESPONSE_FORMAT: "1", + AZURE_OPENAI_MAX_CONCURRENCY: "8", + }); + const out = configToEnv(config); + expect(out.AZURE_OPENAI_RESPONSE_FORMAT).toBe("1"); + expect(out.AZURE_OPENAI_MAX_CONCURRENCY).toBe("8"); + expect(out.AZURE_OPENAI_MAX_TIMEOUT).toBe("60000"); + expect(out.AZURE_OPENAI_MAX_RETRYATTEMPTS).toBe("3"); + }); + + test("preserves extras verbatim", () => { + const flat: FlatEnv = { + AZURE_FOUNDRY_AGENT_ID_FOO: "asst_xyz", + CUSTOM_THING: "whatever", + }; + const out = configToEnv(buildConfig(flat)); + expect(out.AZURE_FOUNDRY_AGENT_ID_FOO).toBe("asst_xyz"); + expect(out.CUSTOM_THING).toBe("whatever"); + }); + + test("typed values for unmigrated extra keys: explicit extras win", () => { + // If both a typed value and an extras override exist, extras win + // (because we haven't decided to lock the typed value yet). + // Here AZURE_OPENAI_RESPONSE_FORMAT is typed, but if a user puts + // it in the input, we read it through the typed path. The extras + // override path applies only to keys we don't recognize. + const flat: FlatEnv = { + AZURE_OPENAI_RESPONSE_FORMAT: "1", + }; + const out = configToEnv(buildConfig(flat)); + expect(out.AZURE_OPENAI_RESPONSE_FORMAT).toBe("1"); + }); +}); + +describe("applyToProcessEnv", () => { + test("does not overwrite existing env values by default", () => { + const target: NodeJS.ProcessEnv = { + AZURE_OPENAI_MAX_CONCURRENCY: "99", + }; + const config = buildConfig({}); + applyToProcessEnv(config, { target }); + // Existing value preserved. + expect(target.AZURE_OPENAI_MAX_CONCURRENCY).toBe("99"); + }); + + test("overwrite=true replaces existing env values", () => { + const target: NodeJS.ProcessEnv = { + AZURE_OPENAI_MAX_CONCURRENCY: "99", + }; + const config = buildConfig({}); + applyToProcessEnv(config, { target, overwrite: true }); + expect(target.AZURE_OPENAI_MAX_CONCURRENCY).toBe("4"); + }); + + test("populates only typed + extra keys, not random globals", () => { + const target: NodeJS.ProcessEnv = { PATH: "/should/stay" }; + const config = buildConfig({ + AZURE_OPENAI_API_KEY: "identity", + CUSTOM_KEY: "v", + }); + applyToProcessEnv(config, { target }); + expect(target.PATH).toBe("/should/stay"); + expect(target.AZURE_OPENAI_API_KEY).toBe("identity"); + expect(target.CUSTOM_KEY).toBe("v"); + }); +}); diff --git a/ts/packages/config/test/schema.spec.ts b/ts/packages/config/test/schema.spec.ts new file mode 100644 index 0000000000..8db3e8b411 --- /dev/null +++ b/ts/packages/config/test/schema.spec.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { validateConfigTree } from "../src/schema.js"; + +describe("validateConfigTree", () => { + test("accepts a simple flat passthrough document", () => { + expect(() => + validateConfigTree( + { + env: { + AZURE_OPENAI_ENDPOINT: "https://x", + AZURE_OPENAI_API_KEY: "identity", + }, + }, + "test.yaml", + ), + ).not.toThrow(); + }); + + test("accepts a nested structured document", () => { + expect(() => + validateConfigTree( + { + azure: { + openai: { + endpoint: "https://x", + api_key: "identity", + response_format: true, + max_concurrency: 4, + }, + }, + }, + "test.yaml", + ), + ).not.toThrow(); + }); + + test("accepts null leaves", () => { + expect(() => + validateConfigTree({ openai: { api_key: null } }, "test.yaml"), + ).not.toThrow(); + }); + + test("rejects arrays", () => { + expect(() => + validateConfigTree({ deployments: ["a", "b"] }, "test.yaml"), + ).toThrow(/Invalid TypeAgent config in test\.yaml/); + }); + + test("error message includes file label and key path", () => { + try { + validateConfigTree( + { azure: { openai: { extras: ["nope"] } } }, + "myfile.yaml", + ); + fail("expected validateConfigTree to throw"); + } catch (e) { + const msg = (e as Error).message; + expect(msg).toContain("myfile.yaml"); + // zod's recursive-union error reports the outermost + // failing path; the deeper path is captured in the + // surrounding tree but not always surfaced. We just + // require that *some* key path appears. + expect(msg).toMatch(/- (azure|):/); + } + }); +}); diff --git a/ts/packages/config/test/tsconfig.json b/ts/packages/config/test/tsconfig.json new file mode 100644 index 0000000000..0e71ed8c2d --- /dev/null +++ b/ts/packages/config/test/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist/test", + "types": ["node", "jest"] + }, + "include": ["./**/*"], + "ts-node": { + "esm": true + }, + "references": [{ "path": "../src" }] +} diff --git a/ts/packages/config/tsconfig.json b/ts/packages/config/tsconfig.json new file mode 100644 index 0000000000..cdf483e0d6 --- /dev/null +++ b/ts/packages/config/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "include": [], + "references": [{ "path": "./src" }, { "path": "./test" }], + "ts-node": { + "esm": true + } +} diff --git a/ts/packages/defaultAgentProvider/package.json b/ts/packages/defaultAgentProvider/package.json index 7d750c4f67..be508885af 100644 --- a/ts/packages/defaultAgentProvider/package.json +++ b/ts/packages/defaultAgentProvider/package.json @@ -41,6 +41,7 @@ "@typeagent/agent-rpc": "workspace:*", "@typeagent/agent-sdk": "workspace:*", "@typeagent/common-utils": "workspace:*", + "@typeagent/config": "workspace:*", "action-grammar": "workspace:*", "agent-cache": "workspace:*", "agent-dispatcher": "workspace:*", diff --git a/ts/packages/defaultAgentProvider/test/construction.spec.ts b/ts/packages/defaultAgentProvider/test/construction.spec.ts index 9730e7628c..998e0985ac 100644 --- a/ts/packages/defaultAgentProvider/test/construction.spec.ts +++ b/ts/packages/defaultAgentProvider/test/construction.spec.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import { createSchemaInfoProvider, diff --git a/ts/packages/defaultAgentProvider/test/constructionCacheTestCommon.ts b/ts/packages/defaultAgentProvider/test/constructionCacheTestCommon.ts index 19de6a1652..89a5ae72e2 100644 --- a/ts/packages/defaultAgentProvider/test/constructionCacheTestCommon.ts +++ b/ts/packages/defaultAgentProvider/test/constructionCacheTestCommon.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import path from "node:path"; import fs from "node:fs"; diff --git a/ts/packages/defaultAgentProvider/test/grammar.spec.ts b/ts/packages/defaultAgentProvider/test/grammar.spec.ts index a84a82ca08..4fe77278fe 100644 --- a/ts/packages/defaultAgentProvider/test/grammar.spec.ts +++ b/ts/packages/defaultAgentProvider/test/grammar.spec.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import { createSchemaInfoProvider, diff --git a/ts/packages/defaultAgentProvider/test/grammarNfa.spec.ts b/ts/packages/defaultAgentProvider/test/grammarNfa.spec.ts index b7fde1f694..0ea8efd481 100644 --- a/ts/packages/defaultAgentProvider/test/grammarNfa.spec.ts +++ b/ts/packages/defaultAgentProvider/test/grammarNfa.spec.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import { createSchemaInfoProvider, diff --git a/ts/packages/defaultAgentProvider/test/grammarNfaIntegration.spec.ts b/ts/packages/defaultAgentProvider/test/grammarNfaIntegration.spec.ts index 9f7e5b4005..578e2609c7 100644 --- a/ts/packages/defaultAgentProvider/test/grammarNfaIntegration.spec.ts +++ b/ts/packages/defaultAgentProvider/test/grammarNfaIntegration.spec.ts @@ -21,8 +21,8 @@ * are treated as failures so they show up in the Jest summary. */ -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import * as path from "path"; import * as fs from "fs"; diff --git a/ts/packages/defaultAgentProvider/test/nfaGrammarCoverage.spec.ts b/ts/packages/defaultAgentProvider/test/nfaGrammarCoverage.spec.ts index 1b782d92fe..eb244ae3ab 100644 --- a/ts/packages/defaultAgentProvider/test/nfaGrammarCoverage.spec.ts +++ b/ts/packages/defaultAgentProvider/test/nfaGrammarCoverage.spec.ts @@ -13,8 +13,8 @@ * Run `npm test -- --verbose` to see the printed table. */ -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import { createSchemaInfoProvider, diff --git a/ts/packages/defaultAgentProvider/test/translateTestCommon.ts b/ts/packages/defaultAgentProvider/test/translateTestCommon.ts index 9ac243be74..424bd75b74 100644 --- a/ts/packages/defaultAgentProvider/test/translateTestCommon.ts +++ b/ts/packages/defaultAgentProvider/test/translateTestCommon.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import { getPackageFilePath } from "../src/utils/getPackageFilePath.js"; import { getDefaultAppAgentProviders } from "../src/defaultAgentProviders.js"; diff --git a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts index e4362bd1f5..876252e914 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts @@ -149,6 +149,14 @@ export class AppAgentManager implements ActionConfigProvider { // {state: "ready"} for them. Cleared on agent disable; re-populated by // setup() and explicit refresh(). private readonly readiness = new Map(); + // Set of agents observed to implement `checkReadiness` at any point + // this session. Sticky on purpose: once we know an agent supports + // readiness, we keep that fact even after the agent is disabled and + // its session context torn down, so the `@config agent` table can + // distinguish "agent supports readiness but isn't currently probed" + // (❓ badge) from "agent doesn't implement readiness, assume ready" + // (no badge). Cleared only on dispatcher shutdown. + private readonly readinessImplementers = new Set(); // Persistent per-app-agent load failure cache. Populated in the failure // branches of setState (action/command init paths — provider load, // initializeAgentContext, etc.) and cleared on successful (re-)enable. @@ -220,6 +228,23 @@ export class AppAgentManager implements ActionConfigProvider { return this.readiness.get(appAgentName) ?? { state: "ready" }; } + // True iff this agent has been observed to implement checkReadiness + // at any point this session AND we currently don't have a cached + // report for it. In practice this means: the agent was enabled at + // some point in a previous process (we know from a persisted hint or + // future extension), or the readiness entry was explicitly cleared. + // Note that disabling an agent does NOT clear its cached entry, so a + // previously-ready agent that's now disabled still reports its last + // known state instead of "unknown" — we have no reason to forget + // working information. UI surfaces use this to show an "unknown" + // indicator only when it's actually meaningful. + public hasUnknownReadiness(appAgentName: string): boolean { + return ( + this.readinessImplementers.has(appAgentName) && + !this.readiness.has(appAgentName) + ); + } + // Returns the most recent load failure for this agent, or undefined if // it loaded cleanly. See `loadErrors` field comment for lifecycle. public getLoadError(appAgentName: string): Error | undefined { @@ -1464,6 +1489,7 @@ export class AppAgentManager implements ActionConfigProvider { // the agent — a misbehaving checkReadiness shouldn't deny the // user the agent. if (appAgent.checkReadiness !== undefined) { + this.readinessImplementers.add(record.name); try { const report = await appAgent.checkReadiness( record.sessionContext!, @@ -1508,9 +1534,13 @@ export class AppAgentManager implements ActionConfigProvider { record.sessionContext = undefined; record.sessionContextP = undefined; record.sessionContextId = undefined; - // Drop cached readiness — the session is going away, the next - // enable will repopulate via initializeSessionContext. - this.readiness.delete(record.name); + // Preserve the cached readiness entry across disable/enable. The + // last probe is the best information we have until the agent is + // re-enabled and re-probed; dropping it would force every + // disable cycle to show "(?)" in `@config agent` for a fact we + // already know. The next initializeSessionContext overwrites the + // entry with a fresh probe anyway, so staleness is bounded to + // the disabled window. try { const sessionContext = await sessionContextP; // Since we have a session context, appAgent must be defined as well. diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts index c3513685dd..ffe642cf50 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts @@ -213,6 +213,7 @@ function buildAgentStatusHtml( agents: { getAppAgentEmoji(name: string): string; getReadiness(name: string): ReadinessReport; + hasUnknownReadiness(name: string): boolean; getLoadError(name: string): Error | undefined; }, showSchema: boolean, @@ -239,8 +240,9 @@ function buildAgentStatusHtml( const tdStyle = `text-align:center;padding:3px 8px;border-bottom:${bb}`; // Per-agent badges (app-agent rows only): load failure first, then - // readiness warning. Distinct icons + colors so they don't get - // conflated with the disabled-state ❌ in the status columns. + // readiness warning (or unknown indicator). Distinct icons + colors + // so they don't get conflated with the disabled-state ❌ in the + // status columns. let warning = ""; if (isAppAgent) { const loadError = agents.getLoadError(name); @@ -248,12 +250,23 @@ function buildAgentStatusHtml( const tip = `Failed to load: ${loadError.message ?? String(loadError)}`; warning += ` `; } - const report = agents.getReadiness(name); - if (report.state !== "ready") { - const tip = report.message - ? `${report.state}: ${report.message}` - : report.state; - warning += ` `; + if (agents.hasUnknownReadiness(name)) { + // Agent is known to implement checkReadiness but hasn't + // been probed this session (typically: currently + // disabled). We can't probe it without spinning up its + // session context, so surface the uncertainty instead + // of implicitly claiming it's ready. + const tip = + "Readiness state unknown — agent is not currently loaded. Enable it (or run `@config agent refresh ` after enabling) to re-probe its setup state."; + warning += ` `; + } else { + const report = agents.getReadiness(name); + if (report.state !== "ready") { + const tip = report.message + ? `${report.state}: ${report.message}` + : report.state; + warning += ` `; + } } } @@ -272,7 +285,7 @@ function buildAgentStatusHtml( return `${headerCols.join("")}${rows.join("")}
`; } -function showAgentStatus( +async function showAgentStatus( toggle: AgentToggle, context: ActionContext, changes?: ChangedAgent, @@ -280,6 +293,16 @@ function showAgentStatus( const systemContext = context.sessionContext.agentContext; const agents = systemContext.agents; + // Bring readiness state up to date for any loaded agent before we + // render, so the table reflects current reality (env vars, files, + // etc. may have changed since the agent's last probe). For agents + // that are disabled (no session context), refreshReadiness is a + // no-op — only agents known to implement checkReadiness but not + // currently loaded get a ❓ badge via hasUnknownReadiness. + await Promise.all( + agents.getAppAgentNames().map((name) => agents.refreshReadiness(name)), + ); + const status: StatusRecords = {}; const showSchema = @@ -366,9 +389,16 @@ function showAgentStatus( if (agents.getLoadError(name) !== undefined) { displayName = `${displayName} ${chalk.red("(err)")}`; } - const report = agents.getReadiness(name); - if (report.state !== "ready") { - displayName = `${displayName} ${chalk.yellow("(!)")}`; + if (agents.hasUnknownReadiness(name)) { + // Implements checkReadiness but not currently loaded. + // Distinct "(?)" marker so users don't read silence as + // "this agent is fine" when it actually has a probe. + displayName = `${displayName} ${chalk.gray("(?)")}`; + } else { + const report = agents.getReadiness(name); + if (report.state !== "ready") { + displayName = `${displayName} ${chalk.yellow("(!)")}`; + } } } table.push( @@ -522,7 +552,7 @@ class AgentToggleCommandHandler implements CommandHandler { // report modified agent status if (!hasParams) { - showAgentStatus(this.toggle, context); + await showAgentStatus(this.toggle, context); return; } @@ -534,7 +564,7 @@ class AgentToggleCommandHandler implements CommandHandler { if (changed === undefined) { displayWarn("No change", context); } else { - showAgentStatus(this.toggle, context, changed); + await showAgentStatus(this.toggle, context, changed); } } diff --git a/ts/packages/dispatcher/dispatcher/src/translation/translateRequest.ts b/ts/packages/dispatcher/dispatcher/src/translation/translateRequest.ts index 2c35823eb2..73b251f51f 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/translateRequest.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/translateRequest.ts @@ -652,10 +652,12 @@ async function finalizeMultipleActions( context: ActionContext, usageCallback: CompleteUsageStatsCallback, ): Promise { - if (attachments !== undefined && attachments.length !== 0) { - // TODO: What to do with attachments with multiple actions? - throw new Error("Attachments with multiple actions not supported"); - } + // When the translator decomposes a request with attachments (e.g. + // "edit this image so X and Y") into a MultipleAction, route the + // same attachment list to every sub-action. The attachments are + // already persisted in session storage by the request handler, so + // passing them along here only affects any sub-action re-translation + // — sub-handlers read bytes back via session storage by filename. const requests = action.parameters.requests; const actions: ExecutableAction[] = []; for (const request of requests) { @@ -671,7 +673,7 @@ async function finalizeMultipleActions( schemaName, activeSchemas, history, - undefined, // TODO: What to do with attachments with multiple actions? + attachments, context, usageCallback, request.resultEntityId, diff --git a/ts/packages/knowledgeProcessor/package.json b/ts/packages/knowledgeProcessor/package.json index 6b01776ba2..2f569e44e9 100644 --- a/ts/packages/knowledgeProcessor/package.json +++ b/ts/packages/knowledgeProcessor/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@azure-rest/maps-search": "^2.0.0-beta.3", + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "debug": "^4.4.0", "exifreader": "^4.30.1", diff --git a/ts/packages/knowledgeProcessor/test/conversation.test.ts b/ts/packages/knowledgeProcessor/test/conversation.test.ts index 3710e7d8a9..2f681f3e90 100644 --- a/ts/packages/knowledgeProcessor/test/conversation.test.ts +++ b/ts/packages/knowledgeProcessor/test/conversation.test.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import path from "node:path"; import os from "node:os"; diff --git a/ts/packages/knowledgeProcessor/test/knowledgeIndex.spec.ts b/ts/packages/knowledgeProcessor/test/knowledgeIndex.spec.ts index 92a3ee6706..cf2fa001ba 100644 --- a/ts/packages/knowledgeProcessor/test/knowledgeIndex.spec.ts +++ b/ts/packages/knowledgeProcessor/test/knowledgeIndex.spec.ts @@ -1,13 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import path from "path"; import { getRootDataPath, hasTestKeys, testIf } from "./testCore.js"; import { cleanDir } from "typeagent"; import { createTextIndex } from "../src/textIndex.js"; import { TextBlock, TextBlockType } from "../src/text.js"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +loadConfigSync(); describe("KnowledgeIndex", () => { const testTimeout = 120000; diff --git a/ts/packages/knowledgeProcessor/test/knowlege.test.ts b/ts/packages/knowledgeProcessor/test/knowlege.test.ts index cfbcb0f5c3..6e32b3afeb 100644 --- a/ts/packages/knowledgeProcessor/test/knowlege.test.ts +++ b/ts/packages/knowledgeProcessor/test/knowlege.test.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import { createTestModels, diff --git a/ts/packages/knowledgeProcessor/test/searchProcessor.test.ts b/ts/packages/knowledgeProcessor/test/searchProcessor.test.ts index d71d4073bb..1a3f25946a 100644 --- a/ts/packages/knowledgeProcessor/test/searchProcessor.test.ts +++ b/ts/packages/knowledgeProcessor/test/searchProcessor.test.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import { createTestModels, shouldSkip, skipTest } from "./testCore.js"; import path from "path"; diff --git a/ts/packages/knowledgeProcessor/test/testCore.ts b/ts/packages/knowledgeProcessor/test/testCore.ts index 5b3e175cb0..29141d97d9 100644 --- a/ts/packages/knowledgeProcessor/test/testCore.ts +++ b/ts/packages/knowledgeProcessor/test/testCore.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -dotenv.config({ path: new URL("../../../../.env", import.meta.url) }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import { ChatModel, diff --git a/ts/packages/mcp/thoughts/package.json b/ts/packages/mcp/thoughts/package.json index f2349f973f..3907c6969b 100644 --- a/ts/packages/mcp/thoughts/package.json +++ b/ts/packages/mcp/thoughts/package.json @@ -36,6 +36,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.101", "@anthropic-ai/sdk": "^0.91.1", + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "dotenv": "^16.4.5", "microsoft-cognitiveservices-speech-sdk": "^1.40.0" diff --git a/ts/packages/mcp/thoughts/src/cli.ts b/ts/packages/mcp/thoughts/src/cli.ts index dff0af249e..d53e402611 100644 --- a/ts/packages/mcp/thoughts/src/cli.ts +++ b/ts/packages/mcp/thoughts/src/cli.ts @@ -2,32 +2,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { config } from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; import * as path from "path"; -import { fileURLToPath } from "url"; import * as fs from "fs"; import { ThoughtsProcessor } from "./thoughtsProcessor.js"; import { transcribeWavFile } from "./audioTranscriber.js"; -// Load .env file from the TypeAgent repository root (ts directory) -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -// From dist/ go up to: thoughts/ -> mcp/ -> packages/ -> ts/ -const repoRoot = path.resolve(__dirname, "../../../.."); -const envPath = path.join(repoRoot, ".env"); - -// Check if .env file exists -if (!fs.existsSync(envPath)) { - console.error(`Warning: .env file not found at ${envPath}`); - console.error( - "Azure Speech credentials will need to be set via environment variables.", - ); -} else { - const result = config({ path: envPath }); - if (result.error) { - console.error(`Warning: Error loading .env file: ${result.error}`); - } -} +loadConfigSync(); interface CliOptions { input?: string; // Input file path or "-" for stdin diff --git a/ts/packages/shell/README.md b/ts/packages/shell/README.md index 5c149ad2f6..910e47a0a2 100644 --- a/ts/packages/shell/README.md +++ b/ts/packages/shell/README.md @@ -71,7 +71,7 @@ In local mode (no agent server), only a single default conversation is available Currently, TypeAgent Shell optionally supports voice input via Azure Speech Services or [Local Whisper Service](../../../python/stt/whisperService/) in addition to keyboard input. -To set up Azure [Speech to Text service](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/index-speech-to-text), the following variables in the `.env` are needed: +To set up Azure [Speech to Text service](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/index-speech-to-text), the following variables in `config.local.yaml` (under `speech`) or the legacy `.env` are needed: | Variable | Value | | --------------------- | -------------------------------------------------------------------------------- | @@ -83,7 +83,7 @@ To set up Azure [Speech to Text service](https://learn.microsoft.com/en-us/azure If you would like to enable keyless Speech API access you must have performed the following steps: -1. Specify `identity` as the `SPEECH_SDK_KEY` in the `.env` file. +1. Specify `identity` as the `SPEECH_SDK_KEY` in `config.local.yaml` (set `speech.key: identity`) or the legacy `.env` file. 2. Replace the `SPEECH_SDK_ENDPOINT` value with the azure resource id of your cognitive service instance (i.e. `/subscriptions//resourceGroups/myResourceGroup/providers/Microsoft.CognitiveServices/accounts/speechapi`). 3. Configure your speech API to support Azure Entra RBAC and add the necessary users/groups with the necessary permissions (typically `Cognitive Services Speech User` or `Cognitive Services Speech Contributor`). More information on cognitive services roles [here](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/role-based-access-control). diff --git a/ts/packages/shell/package.json b/ts/packages/shell/package.json index c5f338d25b..3a0c340433 100644 --- a/ts/packages/shell/package.json +++ b/ts/packages/shell/package.json @@ -24,7 +24,7 @@ "build:electron:esm": "electron-vite build", "clean": "rimraf --glob out dist deploy *.tsbuildinfo *.done.build.log", "deploy": "rimraf ./deploy && pnpm deploy --filter=agent-shell --prod ./deploy --ignore-scripts && pnpm -C deploy run postinstall ", - "dev": "npm run prepare-vite && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-vite dev -- --env ../../.env", + "dev": "npm run prepare-vite && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-vite dev", "dev:noenv": "npm run prepare-vite && electron-vite dev", "postinstall": "run-script-os", "jest-esm": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", @@ -42,9 +42,9 @@ "prettier:fix": "prettier --write . --ignore-path ../../.prettierignore", "shell:smoke": "npx playwright test simple.spec.ts", "shell:test": "pnpm run jest-esm && npx playwright test", - "start": "npm run prepare-vite && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-vite preview -- --env ../../.env", - "start:connect": "npm run prepare-vite && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-vite preview -- --env ../../.env --connect", - "start:nosandbox": "npm run prepare-vite && electron-vite preview --noSandbox -- --env ../../.env", + "start": "npm run prepare-vite && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-vite preview", + "start:connect": "npm run prepare-vite && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-vite preview -- --connect", + "start:nosandbox": "npm run prepare-vite && electron-vite preview --noSandbox", "start:package": "run-script-os", "start:package:linux": "./dist/linux-unpacked/typeagentshell", "start:package:macos": "open ./dist/mac-arm64/TypeAgent\\ Shell.app", @@ -64,6 +64,7 @@ "@typeagent/agent-server-client": "workspace:*", "@typeagent/common-utils": "workspace:*", "@typeagent/completion-ui": "workspace:*", + "@typeagent/config": "workspace:*", "@typeagent/dispatcher-rpc": "workspace:*", "agent-dispatcher": "workspace:*", "aiclient": "workspace:*", @@ -76,6 +77,7 @@ "dotenv": "^16.3.1", "electron-updater": "^6.6.2", "jose": "^5.9.6", + "js-yaml": "^4.1.0", "markdown-it": "^14.1.1", "microsoft-cognitiveservices-speech-sdk": "^1.38.0", "typeagent": "workspace:*", @@ -91,6 +93,7 @@ "@playwright/test": "^1.55.0", "@types/debug": "^4.1.12", "@types/jest": "^29.5.7", + "@types/js-yaml": "^4.0.9", "concurrently": "^9.1.2", "cross-env": "^7.0.3", "electron": "40.8.5", diff --git a/ts/packages/shell/src/main/index.ts b/ts/packages/shell/src/main/index.ts index c5e9fb0380..a4c7547712 100644 --- a/ts/packages/shell/src/main/index.ts +++ b/ts/packages/shell/src/main/index.ts @@ -35,7 +35,7 @@ import { debugShellError, debugShellInit, } from "./debug.js"; -import { loadKeys, loadKeysFromEnvFile } from "./keys.js"; +import { loadKeys, loadKeysFromEnvFile, tryLoadYamlConfig } from "./keys.js"; import { parseShellCommandLine } from "./args.js"; import { setUpdateConfigPath, @@ -140,8 +140,7 @@ const time = performance.now(); debugShellInit("Starting..."); async function initializeKeys(appPath: string) { - // TODO: connected mode only needs the speech key. - // Implement better way to provide and manage keys. + // Prefer YAML config, fall back to legacy .env / DPAPI key cache. const envFile = parsedArgs.env ? path.resolve(appPath, parsedArgs.env) : undefined; @@ -150,13 +149,16 @@ async function initializeKeys(appPath: string) { throw new Error("Test mode requires --env argument"); } await loadKeysFromEnvFile(envFile); - } else { - await loadKeys( - instanceDir, - parsedArgs.reset || parsedArgs.clean, - envFile, - ); + return; + } + + // Try YAML config (handles workspace root discovery internally). + if (tryLoadYamlConfig(envFile)) { + return; } + + // Legacy fallback: .env file + DPAPI-encrypted key cache. + await loadKeys(instanceDir, parsedArgs.reset || parsedArgs.clean, envFile); } // This method will be called when Electron has finished diff --git a/ts/packages/shell/src/main/keys.ts b/ts/packages/shell/src/main/keys.ts index 89ecc31e16..bdebfe6f1d 100644 --- a/ts/packages/shell/src/main/keys.ts +++ b/ts/packages/shell/src/main/keys.ts @@ -8,6 +8,13 @@ import { import path from "node:path"; import fs from "node:fs"; import dotenv from "dotenv"; +import { + flatten as flattenYamlConfig, + loadConfigSync, + type ConfigTree, +} from "@typeagent/config"; +import { initRuntimeConfigFromProcessEnv } from "aiclient"; +import yaml from "js-yaml"; import { debugShell, debugShellError } from "./debug.js"; @@ -20,6 +27,50 @@ export function getKeysPersistencePath(dir: string | undefined) { type ParsedKeys = dotenv.DotenvParseOutput; +/** + * Detect YAML config files by extension and parse them through the + * @typeagent/config flattener so they yield the same + * `KEY=value` shape that dotenv would have produced. Falls back to + * `dotenv.parse` for everything else (including extensionless `.env`). + */ +function parseConfigFileContent(fileName: string, content: string): ParsedKeys { + const ext = path.extname(fileName).toLowerCase(); + if (ext === ".yaml" || ext === ".yml") { + const tree = yaml.load(content, { filename: fileName }); + if (tree === null || tree === undefined) { + return {}; + } + if (typeof tree !== "object" || Array.isArray(tree)) { + throw new Error(`${fileName}: top level must be a YAML mapping.`); + } + const flat = flattenYamlConfig(tree as ConfigTree); + // dotenv.populate (used downstream) accepts a plain object of + // string values; coerce any non-strings just in case. + const out: ParsedKeys = {}; + for (const [k, v] of Object.entries(flat)) { + out[k] = String(v); + } + return out; + } + return dotenv.parse(Buffer.from(content)); +} + +/** + * Persist parsed keys uniformly as `KEY=value` text so the existing + * DPAPI-encrypted cache can re-hydrate them via `dotenv.parse` on + * subsequent launches, regardless of the source file's format. + */ +function serializeParsedKeysForCache(parsed: ParsedKeys): string { + const lines: string[] = []; + for (const [k, v] of Object.entries(parsed)) { + // Quote to preserve trailing whitespace / special chars; escape + // any embedded double quotes and newlines. + const escaped = v.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + lines.push(`${k}="${escaped}"`); + } + return lines.join("\n") + "\n"; +} + async function createPersistence(dir: string | undefined) { const cachePath = getKeysPersistencePath(dir); return PersistenceCreator.createPersistence({ @@ -54,6 +105,11 @@ async function saveKeysToPersistence(dir: string | undefined, keys: string) { function populateKeys(parsed: ParsedKeys) { dotenv.populate(process.env as any, parsed, { override: true }); + // After process.env is populated, build the typed runtime Config + // once so all aiclient consumers can read it via getRuntimeConfig(). + // Legacy callers still see the same values via process.env. + initRuntimeConfigFromProcessEnv(); + debugShell("Runtime Config initialized from process.env"); } function parsedKeysEqual(a: ParsedKeys, b: ParsedKeys) { @@ -82,9 +138,10 @@ async function getParsedKeys( } debugShell("Loading service keys from file", envFile); const content = await fs.promises.readFile(envFile, "utf-8"); - const parsedContent = dotenv.parse(Buffer.from(content)); + const parsedContent = parseConfigFileContent(envFile, content); + const cacheText = serializeParsedKeysForCache(parsedContent); if (parsed === null) { - await saveKeysToPersistence(dir, content); + await saveKeysToPersistence(dir, cacheText); return parsedContent; } @@ -97,7 +154,7 @@ async function getParsedKeys( }); if (result.response === 0) { - await saveKeysToPersistence(dir, content); + await saveKeysToPersistence(dir, cacheText); return parsedContent; } } else { @@ -115,12 +172,23 @@ async function getParsedKeys( // Use the sync version as nothing else is going on. const result = dialog.showOpenDialogSync({ properties: ["openFile", "showHiddenFiles"], - message: "Select .env file", + message: "Select .env or YAML config file", + filters: [ + { name: "Config", extensions: ["env", "yaml", "yml"] }, + { name: "All files", extensions: ["*"] }, + ], }); if (result && result.length > 0) { const content = await fs.promises.readFile(result[0], "utf-8"); - await saveKeysToPersistence(dir, content); - return dotenv.parse(Buffer.from(content)); + const parsedContent = parseConfigFileContent( + result[0], + content, + ); + await saveKeysToPersistence( + dir, + serializeParsedKeysForCache(parsedContent), + ); + return parsedContent; } } } @@ -132,16 +200,50 @@ export async function loadKeysFromEnvFile(envFile: string) { throw new Error(`Env file ${envFile} not found`); } debugShell("Loading service keys from file", envFile); - const keys = await fs.promises.readFile(envFile, "utf-8"); - const parsed = dotenv.parse(Buffer.from(keys)); + const content = await fs.promises.readFile(envFile, "utf-8"); + const parsed = parseConfigFileContent(envFile, content); populateKeys(parsed); } +// Deprecation cutoff: 120 days from 2026-05-11 (the date .env→YAML migration shipped). +const ENV_DEPRECATION_DATE = new Date("2026-05-11"); +const ENV_SUNSET_DAYS = 120; +const ENV_SUNSET_DATE = new Date( + ENV_DEPRECATION_DATE.getTime() + ENV_SUNSET_DAYS * 24 * 60 * 60 * 1000, +); + export async function loadKeys( dir: string | undefined, reset: boolean = false, envFile?: string, ) { + // Hard stop after sunset date — .env key import no longer supported. + if (new Date() >= ENV_SUNSET_DATE) { + debugShellError( + ".env key import has been removed. Use config.local.yaml or Azure Key Vault instead. " + + "See config.sample.yaml for the YAML config format.", + ); + await dialog.showMessageBox({ + type: "error", + buttons: ["OK"], + title: "Service keys — .env import removed", + message: + "The .env file import feature has been removed.\n\n" + + "Please use config.local.yaml (or Azure Key Vault) to configure service keys.\n" + + "See config.sample.yaml in the repo root for the YAML config format.", + }); + return; + } + + // Deprecation warning while still functional. + const daysRemaining = Math.ceil( + (ENV_SUNSET_DATE.getTime() - Date.now()) / (24 * 60 * 60 * 1000), + ); + console.warn( + `[DEPRECATED] .env key import will stop working in ${daysRemaining} days (${ENV_SUNSET_DATE.toLocaleDateString()}). ` + + `Migrate to config.local.yaml or Azure Key Vault. See config.sample.yaml for details.`, + ); + const parsed = await getParsedKeys(dir, reset, envFile); if (parsed) { populateKeys(parsed); @@ -155,3 +257,32 @@ export async function loadKeys( }); } } + +/** + * Try loading YAML config (config.defaults.yaml + config.local.yaml). + * Returns true if config was loaded, false otherwise. + */ +export function tryLoadYamlConfig(envFile?: string): boolean { + const isYamlEnv = + envFile !== undefined && + (envFile.endsWith(".yaml") || envFile.endsWith(".yml")); + + try { + const opts: { strict: boolean; localPath?: string } = { + strict: false, + }; + if (isYamlEnv) { + opts.localPath = envFile; + } + const result = loadConfigSync(opts); + const keyCount = Object.keys(result.env).length; + if (keyCount > 0) { + debugShell("Loaded " + keyCount + " config keys from YAML"); + initRuntimeConfigFromProcessEnv(); + return true; + } + } catch (err) { + debugShellError("YAML config load failed: " + String(err)); + } + return false; +} diff --git a/ts/packages/testLib/package.json b/ts/packages/testLib/package.json index 7115d7d309..fd92acba84 100644 --- a/ts/packages/testLib/package.json +++ b/ts/packages/testLib/package.json @@ -24,6 +24,7 @@ "tsc": "tsc -b" }, "dependencies": { + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "typechat": "^0.1.1" }, diff --git a/ts/packages/testLib/src/models.ts b/ts/packages/testLib/src/models.ts index a7ccd624fc..77a35dde1b 100644 --- a/ts/packages/testLib/src/models.ts +++ b/ts/packages/testLib/src/models.ts @@ -1,9 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; -const envUrl = new URL("../../../.env", import.meta.url); -dotenv.config({ path: envUrl }); +import { loadConfigSync } from "@typeagent/config"; +loadConfigSync(); import { ChatModel, diff --git a/ts/packages/typeagent/package.json b/ts/packages/typeagent/package.json index 0202e96da9..80663d2ecf 100644 --- a/ts/packages/typeagent/package.json +++ b/ts/packages/typeagent/package.json @@ -32,6 +32,7 @@ "tsc": "tsc -b" }, "dependencies": { + "@typeagent/config": "workspace:*", "aiclient": "workspace:*", "async": "^3.2.5", "cheerio": "1.0.0-rc.12", diff --git a/ts/packages/typeagent/test/vector.vectorIndex.spec.ts b/ts/packages/typeagent/test/vector.vectorIndex.spec.ts index 94dbd73ee0..100e67052f 100644 --- a/ts/packages/typeagent/test/vector.vectorIndex.spec.ts +++ b/ts/packages/typeagent/test/vector.vectorIndex.spec.ts @@ -1,11 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import dotenv from "dotenv"; +import { loadConfigSync } from "@typeagent/config"; -dotenv.config({ - path: new URL("../../../../.env", import.meta.url), -}); +loadConfigSync(); import { openai, TextEmbeddingModel } from "aiclient"; import { diff --git a/ts/packages/utils/typechatUtils/src/image.ts b/ts/packages/utils/typechatUtils/src/image.ts index 9d6af1218c..354882162e 100644 --- a/ts/packages/utils/typechatUtils/src/image.ts +++ b/ts/packages/utils/typechatUtils/src/image.ts @@ -150,15 +150,25 @@ export async function addImagePromptContent( }); } + // Resolve the image's GPS position once. If the image has no GPS EXIF + // (e.g. a webcam capture from the photo agent) skip POI / reverse-geocode + // lookups entirely — both helpers early-return for `position === undefined`, + // and `openai.apiSettingsFromEnv()` (which they accept but ignore) throws + // `Missing ApiSetting: AZURE_OPENAI_ENDPOINT` when only suffixed Azure + // deployments are configured. + const gpsPosition = image.exifTags + ? exifGPSTagToLatLong( + image.exifTags.GPSLatitude, + image.exifTags.GPSLatitudeRef, + image.exifTags.GPSLongitude, + image.exifTags.GPSLongitudeRef, + ) + : undefined; + // include POI - if (image.exifTags) { + if (gpsPosition !== undefined) { retValue.nearbyPOI = await findNearbyPointsOfInterest( - exifGPSTagToLatLong( - image.exifTags.GPSLatitude, - image.exifTags.GPSLatitudeRef, - image.exifTags.GPSLongitude, - image.exifTags.GPSLongitudeRef, - ), + gpsPosition, openai.apiSettingsFromEnv(), ); } @@ -170,14 +180,9 @@ export async function addImagePromptContent( } // include address - if (image.exifTags) { + if (gpsPosition !== undefined) { retValue.reverseGeocode = await reverseGeocode( - exifGPSTagToLatLong( - image.exifTags.GPSLatitude, - image.exifTags.GPSLatitudeRef, - image.exifTags.GPSLongitude, - image.exifTags.GPSLongitudeRef, - ), + gpsPosition, openai.apiSettingsFromEnv(), ); } diff --git a/ts/packages/utils/webSocketUtils/package.json b/ts/packages/utils/webSocketUtils/package.json index 25eeb7c14f..dd6e40b933 100644 --- a/ts/packages/utils/webSocketUtils/package.json +++ b/ts/packages/utils/webSocketUtils/package.json @@ -27,6 +27,7 @@ "tsc": "tsc -b" }, "dependencies": { + "@typeagent/config": "workspace:*", "debug": "^4.4.0", "dotenv": "^16.3.1", "find-config": "^1.0.0", diff --git a/ts/packages/utils/webSocketUtils/src/webSockets.ts b/ts/packages/utils/webSocketUtils/src/webSockets.ts index 3ccc9f6636..a45d05e87a 100644 --- a/ts/packages/utils/webSocketUtils/src/webSockets.ts +++ b/ts/packages/utils/webSocketUtils/src/webSockets.ts @@ -3,9 +3,7 @@ import WebSocket from "isomorphic-ws"; import registerDebug from "debug"; -import findConfig from "find-config"; -import dotenv from "dotenv"; -import fs from "node:fs"; +import { loadConfigSync } from "@typeagent/config"; const debug = registerDebug("typeagent:websockets"); @@ -30,16 +28,9 @@ export async function createWebSocket( ) { return new Promise((resolve, reject) => { let endpoint = `ws://localhost:${port}`; + loadConfigSync(); if (process.env["WEBSOCKET_HOST"]) { endpoint = process.env["WEBSOCKET_HOST"]; - } else { - const dotEnvPath = findConfig(".env"); - if (dotEnvPath) { - const vals = dotenv.parse(fs.readFileSync(dotEnvPath)); - if (vals["WEBSOCKET_HOST"]) { - endpoint = vals["WEBSOCKET_HOST"]; - } - } } endpoint += `?channel=${channel}&role=${role}`; diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 7f08190917..5320d6d247 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: shx: specifier: ^0.4.0 version: 0.4.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 examples/agentExamples/echo: dependencies: @@ -171,6 +174,9 @@ importers: '@azure/search-documents': specifier: 12.1.0 version: 12.1.0 + '@typeagent/config': + specifier: workspace:* + version: link:../../packages/config aiclient: specifier: workspace:* version: link:../../packages/aiclient @@ -250,6 +256,9 @@ importers: examples/classify: dependencies: + '@typeagent/config': + specifier: workspace:* + version: link:../../packages/config chalk: specifier: ^5.4.1 version: 5.6.2 @@ -278,6 +287,9 @@ importers: '@typeagent/common-utils': specifier: workspace:* version: link:../../packages/utils/commonUtils + '@typeagent/config': + specifier: workspace:* + version: link:../../packages/config aiclient: specifier: workspace:* version: link:../../packages/aiclient @@ -349,6 +361,9 @@ importers: examples/docuProc: dependencies: + '@typeagent/config': + specifier: workspace:* + version: link:../../packages/config aiclient: specifier: workspace:* version: link:../../packages/aiclient @@ -453,6 +468,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.26.0 version: 1.26.0(zod@4.1.13) + '@typeagent/config': + specifier: workspace:* + version: link:../../packages/config conversation-memory: specifier: workspace:* version: link:../../packages/memory/conversation @@ -493,6 +511,9 @@ importers: '@elastic/elasticsearch': specifier: ^9.3.4 version: 9.3.4 + '@typeagent/config': + specifier: workspace:* + version: link:../../packages/config aiclient: specifier: workspace:* version: link:../../packages/aiclient @@ -588,6 +609,9 @@ importers: examples/playground: dependencies: + '@typeagent/config': + specifier: workspace:* + version: link:../../packages/config aiclient: specifier: workspace:* version: link:../../packages/aiclient @@ -622,6 +646,9 @@ importers: '@typeagent/action-schema': specifier: workspace:* version: link:../../packages/actionSchema + '@typeagent/config': + specifier: workspace:* + version: link:../../packages/config agent-cache: specifier: workspace:* version: link:../../packages/cache @@ -677,6 +704,9 @@ importers: examples/searchActionTest: dependencies: + '@typeagent/config': + specifier: workspace:* + version: link:../../packages/config aiclient: specifier: workspace:* version: link:../../packages/aiclient @@ -757,6 +787,9 @@ importers: '@typeagent/action-schema': specifier: workspace:* version: link:../../packages/actionSchema + '@typeagent/config': + specifier: workspace:* + version: link:../../packages/config aiclient: specifier: workspace:* version: link:../../packages/aiclient @@ -806,6 +839,9 @@ importers: '@typeagent/common-utils': specifier: workspace:* version: link:../../packages/utils/commonUtils + '@typeagent/config': + specifier: workspace:* + version: link:../../packages/config aiclient: specifier: workspace:* version: link:../../packages/aiclient @@ -1055,6 +1091,9 @@ importers: '@typeagent/action-schema': specifier: workspace:* version: link:../actionSchema + '@typeagent/config': + specifier: workspace:* + version: link:../config debug: specifier: ^4.4.0 version: 4.4.1 @@ -1239,6 +1278,9 @@ importers: '@typeagent/action-schema': specifier: workspace:* version: link:../actionSchema + '@typeagent/config': + specifier: workspace:* + version: link:../config action-grammar: specifier: workspace:* version: link:../actionGrammar @@ -1365,6 +1407,9 @@ importers: '@typeagent/common-utils': specifier: workspace:* version: link:../../utils/commonUtils + '@typeagent/config': + specifier: workspace:* + version: link:../../config '@typeagent/dispatcher-rpc': specifier: workspace:* version: link:../../dispatcher/rpc @@ -1542,6 +1587,9 @@ importers: '@typeagent/common-utils': specifier: workspace:* version: link:../../utils/commonUtils + '@typeagent/config': + specifier: workspace:* + version: link:../../config '@typeagent/dispatcher-rpc': specifier: workspace:* version: link:../../dispatcher/rpc @@ -2584,6 +2632,9 @@ importers: '@typeagent/common-utils': specifier: workspace:* version: link:../../utils/commonUtils + '@typeagent/config': + specifier: workspace:* + version: link:../../config chalk: specifier: ^5.4.1 version: 5.6.2 @@ -3121,6 +3172,9 @@ importers: '@azure/identity': specifier: ^4.10.0 version: 4.10.0 + '@typeagent/config': + specifier: workspace:* + version: link:../config async: specifier: ^3.2.5 version: 3.2.6 @@ -3173,6 +3227,9 @@ importers: '@typeagent/agent-sdk': specifier: workspace:* version: link:../agentSdk + '@typeagent/config': + specifier: workspace:* + version: link:../config '@typeagent/dispatcher-rpc': specifier: workspace:* version: link:../dispatcher/rpc @@ -3255,6 +3312,9 @@ importers: '@typeagent/agent-sdk': specifier: workspace:* version: link:../agentSdk + '@typeagent/config': + specifier: workspace:* + version: link:../config aiclient: specifier: workspace:* version: link:../aiclient @@ -3466,6 +3526,9 @@ importers: '@typeagent/common-utils': specifier: workspace:* version: link:../utils/commonUtils + '@typeagent/config': + specifier: workspace:* + version: link:../config '@typeagent/dispatcher-types': specifier: workspace:* version: link:../dispatcher/types @@ -3677,6 +3740,9 @@ importers: '@typeagent/agent-server-client': specifier: workspace:* version: link:../agentServer/client + '@typeagent/config': + specifier: workspace:* + version: link:../config '@typeagent/dispatcher-types': specifier: workspace:* version: link:../dispatcher/types @@ -3736,6 +3802,46 @@ importers: specifier: ~5.4.5 version: 5.4.5 + packages/config: + dependencies: + '@azure/identity': + specifier: ^4.10.0 + version: 4.10.0 + '@azure/keyvault-secrets': + specifier: ^4.9.0 + version: 4.11.2 + debug: + specifier: ^4.4.0 + version: 4.4.3(supports-color@8.1.1) + dotenv: + specifier: ^16.3.1 + version: 16.5.0 + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 + zod: + specifier: ^3.23.8 + version: 3.25.76 + devDependencies: + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 + '@types/jest': + specifier: ^29.5.7 + version: 29.5.14 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + typescript: + specifier: ~5.4.5 + version: 5.4.5 + packages/copilot-plugin: dependencies: '@modelcontextprotocol/sdk': @@ -3790,6 +3896,9 @@ importers: '@typeagent/common-utils': specifier: workspace:* version: link:../utils/commonUtils + '@typeagent/config': + specifier: workspace:* + version: link:../config action-grammar: specifier: workspace:* version: link:../actionGrammar @@ -4407,6 +4516,9 @@ importers: '@azure-rest/maps-search': specifier: ^2.0.0-beta.3 version: 2.0.0-beta.3 + '@typeagent/config': + specifier: workspace:* + version: link:../config aiclient: specifier: workspace:* version: link:../aiclient @@ -4548,6 +4660,9 @@ importers: '@anthropic-ai/sdk': specifier: ^0.91.1 version: 0.91.1(zod@4.3.6) + '@typeagent/config': + specifier: workspace:* + version: link:../../config aiclient: specifier: workspace:* version: link:../../aiclient @@ -4955,6 +5070,9 @@ importers: '@typeagent/completion-ui': specifier: workspace:* version: link:../completionUI + '@typeagent/config': + specifier: workspace:* + version: link:../config '@typeagent/dispatcher-rpc': specifier: workspace:* version: link:../dispatcher/rpc @@ -4991,6 +5109,9 @@ importers: jose: specifier: ^5.9.6 version: 5.10.0 + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 markdown-it: specifier: ^14.1.1 version: 14.1.1 @@ -5031,6 +5152,9 @@ importers: '@types/jest': specifier: ^29.5.7 version: 29.5.14 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 concurrently: specifier: ^9.1.2 version: 9.1.2 @@ -5113,6 +5237,9 @@ importers: packages/testLib: dependencies: + '@typeagent/config': + specifier: workspace:* + version: link:../config aiclient: specifier: workspace:* version: link:../aiclient @@ -5166,6 +5293,9 @@ importers: packages/typeagent: dependencies: + '@typeagent/config': + specifier: workspace:* + version: link:../config aiclient: specifier: workspace:* version: link:../aiclient @@ -5351,6 +5481,9 @@ importers: packages/utils/webSocketUtils: dependencies: + '@typeagent/config': + specifier: workspace:* + version: link:../../config debug: specifier: ^4.4.0 version: 4.4.3(supports-color@8.1.1) @@ -5476,12 +5609,18 @@ importers: '@typeagent/agent-server-client': specifier: workspace:* version: link:../packages/agentServer/client + '@typeagent/config': + specifier: workspace:* + version: link:../packages/config chalk: specifier: ^5.3.0 version: 5.6.2 debug: specifier: ^4.4.0 version: 4.4.1 + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 lodash-es: specifier: ^4.18.1 version: 4.18.1 @@ -8848,6 +8987,9 @@ packages: '@types/jquery@3.5.32': resolution: {integrity: sha512-b9Xbf4CkMqS02YH8zACqN1xzdxc3cO735Qe5AbSUFmyOiaWAbcpqh9Wna+Uk0vgACvoQHpWDg2rGdHkYPLmCiQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/jsdom@20.0.1': resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} @@ -16573,11 +16715,11 @@ snapshots: dependencies: '@azure-rest/core-client': 2.4.0 '@azure/abort-controller': 2.1.2 - '@azure/core-auth': 1.9.0 + '@azure/core-auth': 1.10.1 '@azure/core-lro': 2.7.2 '@azure/core-rest-pipeline': 1.22.2 - '@azure/core-tracing': 1.2.0 - '@azure/logger': 1.2.0 + '@azure/core-tracing': 1.3.1 + '@azure/logger': 1.3.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -16585,9 +16727,9 @@ snapshots: '@azure-rest/core-client@2.4.0': dependencies: '@azure/abort-controller': 2.1.2 - '@azure/core-auth': 1.9.0 + '@azure/core-auth': 1.10.1 '@azure/core-rest-pipeline': 1.22.2 - '@azure/core-tracing': 1.2.0 + '@azure/core-tracing': 1.3.1 '@typespec/ts-http-runtime': 0.2.2 tslib: 2.8.1 transitivePeerDependencies: @@ -16769,8 +16911,8 @@ snapshots: '@azure/core-lro@3.2.0': dependencies: '@azure/abort-controller': 2.1.2 - '@azure/core-util': 1.11.0 - '@azure/logger': 1.2.0 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -17011,8 +17153,8 @@ snapshots: '@azure/opentelemetry-instrumentation-azure-sdk@1.0.0-beta.8': dependencies: - '@azure/core-tracing': 1.2.0 - '@azure/logger': 1.2.0 + '@azure/core-tracing': 1.3.1 + '@azure/logger': 1.3.0 '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) @@ -20672,6 +20814,8 @@ snapshots: dependencies: '@types/sizzle': 2.3.8 + '@types/js-yaml@4.0.9': {} + '@types/jsdom@20.0.1': dependencies: '@types/node': 22.15.18 diff --git a/ts/tools/package.json b/ts/tools/package.json index 4d5ef67d2d..6c6496eb5b 100644 --- a/ts/tools/package.json +++ b/ts/tools/package.json @@ -17,8 +17,10 @@ "@azure/keyvault-certificates": "^4.9.0", "@azure/keyvault-secrets": "^4.9.0", "@typeagent/agent-server-client": "workspace:*", + "@typeagent/config": "workspace:*", "chalk": "^5.3.0", "debug": "^4.4.0", + "js-yaml": "^4.1.0", "lodash-es": "^4.18.1", "semver": "^7.7.2", "sort-package-json": "^3.0.0", diff --git a/ts/tools/scripts/getKeys.config.json b/ts/tools/scripts/getKeys.config.json index 98861b6c94..2f30c10930 100644 --- a/ts/tools/scripts/getKeys.config.json +++ b/ts/tools/scripts/getKeys.config.json @@ -20,8 +20,6 @@ "AZURE_OPENAI_ENDPOINT_EMBEDDING", "AZURE_OPENAI_API_KEY_GPT_IMAGE_1_5", "AZURE_OPENAI_ENDPOINT_GPT_IMAGE_1_5", - "AZURE_OPENAI_API_KEY_DALLE", - "AZURE_OPENAI_ENDPOINT_DALLE", "AZURE_OPENAI_API_KEY_SORA_2", "AZURE_OPENAI_ENDPOINT_SORA_2", "AZURE_OPENAI_API_KEY_GPT_5", @@ -81,7 +79,8 @@ ] }, "vault": { - "shared": "aisystems" + "shared": "aisystems", + "configSecret": "typeagent-config" }, "cert": { "vault": "aisystems", diff --git a/ts/tools/scripts/getKeys.mjs b/ts/tools/scripts/getKeys.mjs index dc292d771a..e21fac9d76 100644 --- a/ts/tools/scripts/getKeys.mjs +++ b/ts/tools/scripts/getKeys.mjs @@ -6,19 +6,22 @@ import fs from "node:fs"; import path from "node:path"; import readline from "node:readline/promises"; import { getClient as getPIMClient } from "./lib/pimClient.mjs"; -import { getAzCliLoggedInInfo } from "./lib/azureUtils.mjs"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import chalk from "chalk"; -import { exit } from "node:process"; -import { DefaultAzureCredential } from "@azure/identity"; +import { + DefaultAzureCredential, + InteractiveBrowserCredential, +} from "@azure/identity"; import { SecretClient } from "@azure/keyvault-secrets"; +import yaml from "js-yaml"; const require = createRequire(import.meta.url); const config = require("./getKeys.config.json"); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const dotenvPath = path.resolve(__dirname, config.defaultDotEnvPath); +const yamlPath = path.resolve(__dirname, "../../config.local.yaml"); const sharedKeys = config.env.shared; const privateKeys = config.env.private; const deleteKeys = config.env.delete; @@ -29,6 +32,7 @@ let paramSharedVault = undefined; let paramPrivateVault = undefined; let paramCommit = true; let paramVerbose = false; +let paramFormat = undefined; // "yaml" | "dotenv" | undefined (defaults to yaml) function nowHHMMSS() { const d = new Date(); @@ -200,19 +204,77 @@ async function getSecrets(keyVaultClient, vaultName, shared) { return { results, failures }; } +// Decode a JWT (no signature verification — we only want the claims to +// print friendly identity info). +function decodeJwtClaims(token) { + try { + const [, payload] = token.split("."); + if (!payload) return undefined; + const b64 = payload.replace(/-/g, "+").replace(/_/g, "/"); + const pad = + b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4)); + const json = Buffer.from(b64 + pad, "base64").toString("utf8"); + return JSON.parse(json); + } catch { + return undefined; + } +} + +const KEY_VAULT_SCOPE = "https://vault.azure.net/.default"; + +// Resolve an Azure credential without shelling out to `az`. Tries +// DefaultAzureCredential first (which already covers az cli, az +// powershell, VS Code, managed identity, env vars, etc.), and if no +// silent credential is available, falls back to InteractiveBrowserCredential +// to force an interactive login. Prints friendly identity info from the +// access token's claims. +async function getAzureCredential() { + const tenantId = process.env.AZURE_TENANT_ID; + const defaultCred = new DefaultAzureCredential( + tenantId ? { tenantId } : undefined, + ); + let token; + try { + token = await defaultCred.getToken(KEY_VAULT_SCOPE); + } catch (e) { + vlog(`silent credential failed: ${e?.message}`); + } + + if (!token) { + console.warn( + chalk.yellowBright( + "No silent Azure credential available — launching interactive browser login...", + ), + ); + const interactive = new InteractiveBrowserCredential({ + ...(tenantId ? { tenantId } : {}), + // Allow the credential to acquire tokens for any tenant the + // user has access to — Key Vaults often live in a different + // tenant than the user's home tenant. + additionallyAllowedTenants: ["*"], + }); + token = await interactive.getToken(KEY_VAULT_SCOPE); + const claims = decodeJwtClaims(token.token); + const who = claims?.upn ?? claims?.preferred_username ?? claims?.name; + if (who) console.log(`Logged in as ${chalk.cyanBright(who)}`); + // Return the interactive credential directly. Wrapping it in a + // ChainedTokenCredential with DefaultAzureCredential causes the + // SDK's downstream getToken calls to incorrectly route through + // DefaultAzureCredential (which has already failed) instead of + // reusing the just-acquired interactive token. + return interactive; + } + + const claims = decodeJwtClaims(token.token); + const who = claims?.upn ?? claims?.preferred_username ?? claims?.name; + if (who) console.log(`Logged in as ${chalk.cyanBright(who)}`); + return defaultCred; +} + class SdkKeyVaultClient { static async get() { - // Print friendly identity info (one az call, like before). The actual - // secret operations below use DefaultAzureCredential — no more shell-outs. - try { - await getAzCliLoggedInInfo(); - } catch (e) { - console.error( - "ERROR: User not logged in to Azure CLI. Run 'az login'.", - ); - process.exit(1); - } - return new SdkKeyVaultClient(new DefaultAzureCredential()); + const credential = await getAzureCredential(); + return new SdkKeyVaultClient(credential); } constructor(credential) { @@ -285,6 +347,69 @@ async function readDotenv() { return dotEnv; } +/** + * Read config.local.yaml and flatten to [key, value] pairs compatible with + * the .env format used by Key Vault secrets. + */ +async function readYamlConfig() { + if (!fs.existsSync(yamlPath)) { + return []; + } + // Dynamic import — @typeagent/config provides the flatten function that + // converts the YAML tree to flat KEY=VALUE pairs identical to .env format. + const { flatten } = await import("@typeagent/config"); + const raw = await fs.promises.readFile(yamlPath, "utf8"); + const tree = yaml.load(raw); + if (!tree || typeof tree !== "object") return []; + const flat = flatten(tree); + return Object.entries(flat); +} + +/** + * Write a Map of env entries back to config.local.yaml by converting + * flat KEY=VALUE pairs through the config pipeline into a structured + * YAML tree. + */ +async function writeYamlConfig(envMap) { + const { envToYamlTree } = await import("@typeagent/config"); + const flat = Object.fromEntries(envMap); + const tree = envToYamlTree(flat); + const header = `# TypeAgent configuration — auto-generated by getKeys on ${new Date().toISOString().slice(0, 10)}\n`; + await fs.promises.writeFile( + yamlPath, + header + + yaml.dump(tree, { + lineWidth: -1, + noRefs: true, + sortKeys: false, + }), + ); +} + +/** + * Detect output format: explicit --dotenv flag selects legacy dotenv mode. + * Otherwise default to YAML (the current standard format). + */ +function resolveFormat() { + if (paramFormat) return paramFormat; + return "yaml"; +} + +/** + * Read config from the appropriate format. + */ +async function readConfig() { + const format = resolveFormat(); + if (format === "yaml") { + return { + entries: await readYamlConfig(), + format: "yaml", + path: yamlPath, + }; + } + return { entries: await readDotenv(), format: "dotenv", path: dotenvPath }; +} + function toSecretKey(envKey) { return envKey.split("_").join("-"); } @@ -375,19 +500,88 @@ function getVaultNames(dotEnv) { } async function pushSecrets() { - const dotEnv = await readDotenv(); + const format = resolveFormat(); + + // YAML mode: push the whole file as a single secret + if (format === "yaml") { + return pushYamlConfig(); + } + + // Legacy dotenv mode: push individual secrets + console.log( + chalk.yellow( + "[DEPRECATED] Pushing individual .env secrets. Use YAML format instead.\n" + + " Run without --dotenv to push config.local.yaml as a single secret.\n", + ), + ); + return pushDotenvSecrets(); +} + +/** + * Push config.local.yaml as a single secret to Key Vault. + */ +async function pushYamlConfig() { + if (!fs.existsSync(yamlPath)) { + console.error(chalk.red(`${yamlPath} not found. Nothing to push.`)); + process.exitCode = 1; + return; + } + + const keyVaultClient = await timed("az login check", () => + getKeyVaultClient(), + ); + const vaultName = paramSharedVault ?? config.vault.shared; + const secretName = config.vault.configSecret ?? "typeagent-config"; + const yamlContent = await fs.promises.readFile(yamlPath, "utf8"); + + console.log( + `Pushing ${chalk.cyanBright(yamlPath)} as '${chalk.cyanBright(secretName)}' to ${chalk.cyanBright(vaultName)} key vault.`, + ); + + if (!paramCommit) { + console.log( + `\n[dry-run] Would write secret '${secretName}' to vault '${vaultName}'.\n` + + `Re-run without ${chalk.yellowBright("--dry-run")} to write.`, + ); + return; + } + + try { + await keyVaultClient.writeSecret(vaultName, secretName, yamlContent); + console.log( + chalk.green( + `\nSecret '${secretName}' updated in vault '${vaultName}'.`, + ), + ); + } catch (e) { + console.error( + chalk.red(`Failed to write '${secretName}': ${e.message}`), + ); + process.exitCode = 1; + } +} + +/** + * Legacy path: push individual secrets from .env to Key Vault. + */ +async function pushDotenvSecrets() { + const { entries, format, path: cfgPath } = await readConfig(); + const dotEnv = entries; const keyVaultClient = await getKeyVaultClient(); - const vaultNames = getVaultNames(dotEnv); + const vaultNames = getVaultNames(new Map(dotEnv)); const sharedSecrets = new Map( - await getSecrets(keyVaultClient, vaultNames.shared, true), + (await getSecrets(keyVaultClient, vaultNames.shared, true)).results, ); const privateSecrets = new Map( vaultNames.private - ? await getSecrets(keyVaultClient, vaultNames.private, false) - : undefined, + ? (await getSecrets(keyVaultClient, vaultNames.private, false)) + .results + : [], ); - console.log(`Pushing secrets from ${dotenvPath} to key vault.`); + console.log( + `Pushing secrets from ${chalk.cyanBright(cfgPath)} (${format}) to key vault.`, + ); let skipped = 0; const jobs = []; const stdio = readline.createInterface(process.stdin, process.stdout); @@ -511,12 +705,107 @@ async function pullSecretsFromVault(keyVaultClient, vaultName, shared, dotEnv) { async function pullSecrets() { const overallStart = Date.now(); + const format = resolveFormat(); + + // YAML mode: pull the single consolidated config secret directly + if (format === "yaml") { + return pullYamlConfig(overallStart); + } + + // Legacy dotenv mode: pull individual secrets + console.log( + chalk.yellow( + "[DEPRECATED] Pulling individual .env secrets. Use YAML format instead (default for new installs).\n" + + " Run without --dotenv to get config.local.yaml.\n", + ), + ); + return pullDotenvSecrets(overallStart); +} + +/** + * Pull the single typeagent-config YAML secret from Key Vault and write + * it directly as config.local.yaml. + */ +async function pullYamlConfig(overallStart) { + const keyVaultClient = await timed("az login check", () => + getKeyVaultClient(), + ); + const vaultName = paramSharedVault ?? config.vault.shared; + const secretName = config.vault.configSecret ?? "typeagent-config"; + + console.log( + `Pulling ${chalk.cyanBright(secretName)} from ${chalk.cyanBright(vaultName)} key vault...`, + ); + + let secretValue; + try { + const response = await keyVaultClient.readSecret(vaultName, secretName); + secretValue = response.value; + } catch (e) { + console.error( + chalk.red( + `Failed to read '${secretName}' from vault '${vaultName}': ${e.message}`, + ), + ); + console.log( + chalk.yellow( + `\nHint: Make sure the '${secretName}' secret exists in the vault.\n` + + ` To push your local config: npm run getKeys -- push --yaml --commit`, + ), + ); + process.exitCode = 1; + return; + } + + if (!secretValue) { + console.error( + chalk.red( + `Secret '${secretName}' is empty in vault '${vaultName}'.`, + ), + ); + process.exitCode = 1; + return; + } + + vlog(`pull total elapsed: ${Date.now() - overallStart}ms`); + + if (!paramCommit) { + console.log( + `\n[dry-run] Would write ${chalk.cyanBright(yamlPath)} from vault secret '${secretName}'.\n` + + `Re-run without ${chalk.yellowBright("--dry-run")} to write.`, + ); + return; + } + + await fs.promises.writeFile(yamlPath, secretValue, "utf8"); + console.log( + `\nWritten ${chalk.cyanBright(yamlPath)} from vault secret '${secretName}'.`, + ); + + // If a legacy .env file exists alongside the new YAML config, warn the + // user so the two formats don't drift out of sync. + if (fs.existsSync(dotenvPath)) { + console.warn( + chalk.yellowBright( + `\nWARNING: Legacy ${chalk.cyanBright(dotenvPath)} still exists.\n` + + ` Only ${chalk.cyanBright(yamlPath)} is used going forward. ` + + `Consider deleting the .env file to avoid confusion.`, + ), + ); + } +} + +/** + * Legacy path: pull individual secrets and assemble into a .env file. + */ +async function pullDotenvSecrets(overallStart) { + const cfgPath = dotenvPath; const dotEnv = new Map(await timed("readDotenv", () => readDotenv())); const keyVaultClient = await timed("az login check", () => getKeyVaultClient(), ); const vaultNames = getVaultNames(dotEnv); - console.log(`Pulling secrets to ${chalk.cyanBright(dotenvPath)}`); + console.log(`Pulling secrets to ${chalk.cyanBright(cfgPath)} (dotenv)`); const sharedResult = await timed( `pullSecretsFromVault(shared=${vaultNames.shared})`, () => @@ -578,7 +867,7 @@ async function pullSecrets() { if (allFailures.length > 0) { console.warn( chalk.yellow( - `\nWARNING: Failed to fetch ${allFailures.length} secret(s) — these values were not updated in .env:`, + `\nWARNING: Failed to fetch ${allFailures.length} secret(s) — these values were not updated:`, ), ); for (const { name, error } of allFailures) { @@ -588,29 +877,61 @@ async function pullSecrets() { } if (updated === 0) { - console.log( - `\nAll values up to date in ${chalk.cyanBright(dotenvPath)}`, - ); + console.log(`\nAll values up to date in ${chalk.cyanBright(cfgPath)}`); return; } if (!paramCommit) { console.log( - `\n[dry-run] ${updated} value(s) would be updated in ${chalk.cyanBright(dotenvPath)}. Re-run with ${chalk.yellowBright("--commit")} to write.`, + `\n[dry-run] ${updated} value(s) would be updated in ${chalk.cyanBright(cfgPath)}. Re-run without ${chalk.yellowBright("--dry-run")} to write.`, ); return; } console.log( - `\n${updated} values updated.\nWriting '${chalk.cyanBright(dotenvPath)}'.`, + `\n${updated} values updated.\nWriting '${chalk.cyanBright(cfgPath)}'.`, ); await fs.promises.writeFile( - dotenvPath, + cfgPath, [...dotEnv.entries()] .map(([key, value]) => (key ? `${key}=${value}` : "")) .join("\n"), ); } +function printHelp() { + console.log(` +${chalk.bold("getKeys")} — Manage TypeAgent secrets in Azure Key Vault + +${chalk.bold("Usage:")} + node getKeys.mjs [command] [options] + +${chalk.bold("Commands:")} + pull Pull config from Key Vault to local file (default) + push Push local config to Key Vault + help Show this help message + +${chalk.bold("Options:")} + --yaml Force YAML format (config.local.yaml) — pulls/pushes a single secret + --dotenv Force legacy .env format — pulls/pushes individual secrets (deprecated) + --vault Shared vault name (default: aisystems) + --private Private vault name + --commit Write changes (default) + --dry-run Preview changes without writing + --verbose Show detailed timing info + +${chalk.bold("Default format:")} + YAML is the default. Pass --dotenv to use the deprecated .env format. + +${chalk.bold("YAML mode (default):")} + pull: Downloads the '${config.vault.configSecret}' secret as config.local.yaml + push: Uploads config.local.yaml as the '${config.vault.configSecret}' secret + +${chalk.bold("Legacy .env mode (--dotenv):")} + pull: Enumerates individual secrets and assembles .env file + push: Pushes individual key=value pairs as separate secrets +`); +} + const commands = ["push", "pull", "help"]; (async () => { const command = commands.includes(process.argv[2]) @@ -653,6 +974,16 @@ const commands = ["push", "pull", "help"]; continue; } + if (arg === "--dotenv") { + paramFormat = "dotenv"; + continue; + } + + if (arg === "--yaml") { + paramFormat = "yaml"; + continue; + } + throw new Error(`Unknown argument: ${arg}`); } switch (command) { @@ -670,20 +1001,6 @@ const commands = ["push", "pull", "help"]; throw new Error(`Unknown argument '${process.argv[2]}'`); } })().catch((e) => { - if ( - e.message.includes( - "'az' is not recognized as an internal or external command", - ) - ) { - console.error( - chalk.red( - `ERROR: Azure CLI is not installed. Install it and run 'az login' before running this tool.`, - ), - ); - // eslint-disable-next-line no-undef - exit(0); - } - console.error(chalk.red(`FATAL ERROR: ${e.stack}`)); process.exit(-1); }); diff --git a/ts/tools/scripts/migrateToYamlSecret.mjs b/ts/tools/scripts/migrateToYamlSecret.mjs new file mode 100644 index 0000000000..e4f6fa5d73 --- /dev/null +++ b/ts/tools/scripts/migrateToYamlSecret.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// One-shot: read all individual secrets from a Key Vault, assemble them +// into a YAML config tree, and write the result as a single +// "typeagent-config" secret in the same vault. Does not touch local files. +// +// Usage: +// node migrateToYamlSecret.mjs --vault [--secret typeagent-config] [--dry-run] + +import { DefaultAzureCredential } from "@azure/identity"; +import { SecretClient } from "@azure/keyvault-secrets"; +import yaml from "js-yaml"; +import chalk from "chalk"; +import { getClient as getPIMClient } from "./lib/pimClient.mjs"; + +let vaultName; +let secretName = "typeagent-config"; +let dryRun = false; + +for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (arg === "--vault") { + vaultName = process.argv[++i]; + } else if (arg === "--secret") { + secretName = process.argv[++i]; + } else if (arg === "--dry-run") { + dryRun = true; + } else { + console.error(`Unknown argument: ${arg}`); + process.exit(1); + } +} + +if (!vaultName) { + console.error("Missing required --vault "); + process.exit(1); +} + +function toEnvKey(secretKey) { + return secretKey.split("-").join("_"); +} + +function isForbidden(e) { + return ( + e?.statusCode === 403 || + (typeof e?.message === "string" && + (e.message.includes("ForbiddenByRbac") || + e.message.includes("Forbidden") || + e.message.includes("statusCode 403"))) + ); +} + +async function elevate(roleName) { + const pimClient = await getPIMClient(); + await pimClient.elevate({ + requestType: "SelfActivate", + roleName, + expirationType: "AfterDuration", + expirationDuration: "PT5M", + continueOnFailure: true, + }); + console.log(chalk.green(`Elevation to '${roleName}' successful.`)); + console.warn(chalk.yellowBright("Waiting 5 seconds...")); + await new Promise((r) => setTimeout(r, 5000)); +} + +async function listSecretsWithElevation() { + try { + return await collectNames(); + } catch (e) { + if (!isForbidden(e)) throw e; + console.warn( + chalk.yellowBright( + "List blocked by RBAC — elevating to Key Vault Administrator ...", + ), + ); + try { + await elevate("Key Vault Administrator"); + } catch { + console.warn( + chalk.yellow( + "Admin elevation failed — trying Key Vault Secrets Officer ...", + ), + ); + await elevate("Key Vault Secrets Officer"); + } + return collectNames(); + } +} + +async function collectNames() { + const result = []; + for await (const props of client.listPropertiesOfSecrets()) { + if (!props.enabled) continue; + const name = props.name; + if (name === secretName) { + console.log(chalk.gray(` skip existing config secret '${name}'`)); + continue; + } + result.push(name); + } + return result; +} + +const credential = new DefaultAzureCredential(); +const client = new SecretClient( + `https://${vaultName}.vault.azure.net`, + credential, +); + +console.log(`Listing secrets in ${chalk.cyanBright(vaultName)} ...`); +const names = await listSecretsWithElevation(); +console.log(`Found ${names.length} enabled secret(s).`); + +const concurrency = 20; +const entries = []; +const failures = []; +for (let i = 0; i < names.length; i += concurrency) { + const batch = names.slice(i, i + concurrency); + const results = await Promise.all( + batch.map(async (n) => { + try { + const s = await client.getSecret(n); + return [n, s.value]; + } catch (e) { + failures.push({ name: n, error: e.message }); + return null; + } + }), + ); + for (const r of results) { + if (r) entries.push(r); + } +} + +if (failures.length) { + console.warn(chalk.yellow(`Failed to read ${failures.length} secret(s):`)); + for (const f of failures) { + console.warn(chalk.yellow(` - ${f.name}: ${f.error}`)); + } +} + +console.log(`Read ${entries.length} secret(s). Converting to YAML ...`); + +const flat = {}; +for (const [secretKey, value] of entries) { + flat[toEnvKey(secretKey)] = value; +} + +const { envToYamlTree } = await import("@typeagent/config"); +const tree = envToYamlTree(flat); +const header = `# TypeAgent configuration — auto-generated by migrateToYamlSecret on ${new Date().toISOString().slice(0, 10)}\n`; +const yamlContent = + header + + yaml.dump(tree, { + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + +console.log( + `YAML payload is ${yamlContent.length} bytes (${entries.length} keys).`, +); + +if (dryRun) { + console.log( + chalk.yellow( + `\n[dry-run] Would write secret '${secretName}' to vault '${vaultName}'.`, + ), + ); + console.log("\n--- YAML preview (first 80 lines) ---"); + console.log(yamlContent.split("\n").slice(0, 80).join("\n")); + console.log("--- end preview ---"); + process.exit(0); +} + +console.log( + `Writing '${chalk.cyanBright(secretName)}' to ${chalk.cyanBright(vaultName)} ...`, +); +await client.setSecret(secretName, yamlContent); +console.log( + chalk.green(`Secret '${secretName}' updated in vault '${vaultName}'.`), +); diff --git a/ts/tools/scripts/testServiceKeys.ts b/ts/tools/scripts/testServiceKeys.ts index 02c0eb93d9..6594c91b50 100644 --- a/ts/tools/scripts/testServiceKeys.ts +++ b/ts/tools/scripts/testServiceKeys.ts @@ -4,34 +4,24 @@ /** * Service Keys Verification Tool for TypeAgent * - * This script tests the service keys configured in the .env file. - * It validates each service separately and provides helpful error messages - * with expected formats for missing or invalid keys. + * This script tests the service keys configured in config.local.yaml (or legacy .env). + * It discovers all Azure OpenAI deployments and endpoints dynamically from the + * structured config, then validates each service with a live connectivity test. * * Usage: - * npx ts-node tools/scripts/testServiceKeys.ts + * npx tsx tools/scripts/testServiceKeys.ts * OR * npm run test:keys */ import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; -import dotenv from "dotenv"; +import { loadConfigSync, buildConfig } from "@typeagent/config"; import { DefaultAzureCredential } from "@azure/identity"; -// Load .env file from the ts directory -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const envPath = path.resolve(__dirname, "../../.env"); - -if (fs.existsSync(envPath)) { - dotenv.config({ path: envPath }); - console.log(`✓ Loaded .env file from: ${envPath}\n`); -} else { - console.log(`⚠ No .env file found at: ${envPath}`); - console.log(" Create a .env file with your service keys.\n"); -} +loadConfigSync(); + +// Build the typed config to discover all deployments/endpoints dynamically +const typedConfig = buildConfig(process.env as Record); // ============================================================================ // Service Key Definitions @@ -87,45 +77,178 @@ function printHeader(msg: string) { } // ============================================================================ -// Service Configurations +// Dynamic Azure OpenAI discovery from typed config // ============================================================================ -const serviceConfigs: ServiceKeyConfig[] = [ - // Azure OpenAI - Chat Model - { - name: "Azure OpenAI (Chat)", - description: "LLM for request translation (GPT-4 or equivalent)", - requiredKeys: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"], - optionalKeys: ["AZURE_OPENAI_RESPONSE_FORMAT"], - expectedFormats: { - AZURE_OPENAI_API_KEY: - "<32-character hex string> or 'identity' for keyless access", - AZURE_OPENAI_ENDPOINT: - "https://.openai.azure.com/openai/deployments//chat/completions?api-version=2024-02-01", - AZURE_OPENAI_RESPONSE_FORMAT: "1 (to enable JSON response format)", - }, - testFunction: testAzureOpenAIChat, - }, +function discoverAzureOpenAIConfigs(): ServiceKeyConfig[] { + const configs: ServiceKeyConfig[] = []; + const ao = typedConfig.azureOpenAI; + + // Test each deployment (chat models) — deployments is a Map + for (const [name, dep] of ao.deployments) { + for (const ep of dep.endpoints) { + if (!ep.endpoint) continue; + const suffix = name === "default" ? "" : `_${name.toUpperCase()}`; + // Add region suffix if the deployment has multiple endpoints + const regionSuffix = + dep.endpoints.length > 1 && ep.region + ? `_${ep.region.toUpperCase()}` + : ""; + const keyVar = `AZURE_OPENAI_API_KEY${suffix}${regionSuffix}`; + const endVar = `AZURE_OPENAI_ENDPOINT${suffix}${regionSuffix}`; + const label = regionSuffix ? `${name} (${ep.region})` : name; + const isEmbedding = name.toLowerCase().includes("embedding"); + configs.push({ + name: `Azure OpenAI — ${label}`, + description: `${dep.model ?? name} (${ep.endpoint.split(".")[0].replace("https://", "")})`, + requiredKeys: [endVar], + optionalKeys: [keyVar], + expectedFormats: { + [endVar]: "https://.openai.azure.com/...", + [keyVar]: " or 'identity'", + }, + testFunction: isEmbedding + ? () => + testEmbeddingEndpointDynamic( + ep.endpoint, + process.env[keyVar], + ) + : () => + testAzureOpenAIEndpointDynamic( + ep.endpoint, + process.env[keyVar], + ), + }); + } + } - // Azure OpenAI - Embeddings - { - name: "Azure OpenAI (Embeddings)", - description: - "Text embeddings for conversation memory and fuzzy matching", - requiredKeys: [ - "AZURE_OPENAI_API_KEY_EMBEDDING", - "AZURE_OPENAI_ENDPOINT_EMBEDDING", - ], - expectedFormats: { - AZURE_OPENAI_API_KEY_EMBEDDING: - "<32-character hex string> or 'identity' for keyless access", - AZURE_OPENAI_ENDPOINT_EMBEDDING: - "https://.openai.azure.com/openai/deployments//embeddings?api-version=2024-02-01", - }, - testFunction: testAzureOpenAIEmbeddings, - }, + return configs; +} + +/** Test a chat deployment/endpoint with a minimal request */ +async function testAzureOpenAIEndpointDynamic( + endpoint: string, + apiKey: string | undefined, +): Promise { + try { + const headers = await getAuthHeaders(apiKey); + const commonHeaders = { + ...headers, + "Content-Type": "application/json", + }; + const messages = [{ role: "user", content: "Say 'test'" }]; + + // Try max_completion_tokens first (newer models), fall back to max_tokens + let response = await fetch(endpoint, { + method: "POST", + headers: commonHeaders, + body: JSON.stringify({ messages, max_completion_tokens: 5 }), + }); + + if (response.status === 400) { + const body = await response.text(); + if (body.includes("max_completion_tokens")) { + // Model doesn't support max_completion_tokens, retry with max_tokens + response = await fetch(endpoint, { + method: "POST", + headers: commonHeaders, + body: JSON.stringify({ messages, max_tokens: 5 }), + }); + } else { + return { + success: false, + message: `HTTP 400`, + details: body.substring(0, 200), + }; + } + } + + if (response.ok) { + const data = await response.json(); + const auth = + apiKey?.toLowerCase() === "identity" ? " (keyless)" : ""; + return { + success: true, + message: `Connected${auth}`, + details: `Tokens: ${data.usage?.total_tokens ?? "?"}`, + }; + } + const errorText = await response.text(); + return { + success: false, + message: `HTTP ${response.status}`, + details: errorText.substring(0, 200), + }; + } catch (error: any) { + return { + success: false, + message: "Connection failed", + details: error.message, + }; + } +} + +/** Test an embedding endpoint with a minimal request */ +async function testEmbeddingEndpointDynamic( + endpoint: string, + apiKey: string | undefined, +): Promise { + try { + const headers = await getAuthHeaders(apiKey); + + const response = await fetch(endpoint, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ input: "test" }), + }); + + if (response.ok) { + const data = await response.json(); + const dim = data.data?.[0]?.embedding?.length; + const auth = + apiKey?.toLowerCase() === "identity" ? " (keyless)" : ""; + return { + success: true, + message: `Connected${auth}`, + details: dim ? `Dimensions: ${dim}` : undefined, + }; + } + const errorText = await response.text(); + return { + success: false, + message: `HTTP ${response.status}`, + details: errorText.substring(0, 200), + }; + } catch (error: any) { + return { + success: false, + message: "Connection failed", + details: error.message, + }; + } +} - // OpenAI - Chat Model +/** Build auth headers — identity-based or API key */ +async function getAuthHeaders( + apiKey: string | undefined, +): Promise> { + if (!apiKey || apiKey.toLowerCase() === "identity") { + const token = await getAzureAccessToken(); + if (!token) + throw new Error( + "Failed to get Azure access token. Run 'az login'.", + ); + return { Authorization: `Bearer ${token}` }; + } + return { "api-key": apiKey }; +} + +// ============================================================================ +// Static service configs (non-Azure-OpenAI) +// ============================================================================ + +const staticServiceConfigs: ServiceKeyConfig[] = [ + // OpenAI (non-Azure) { name: "OpenAI (Chat)", description: "Alternative to Azure OpenAI for request translation", @@ -138,14 +261,9 @@ const serviceConfigs: ServiceKeyConfig[] = [ expectedFormats: { OPENAI_API_KEY: "sk-", OPENAI_ENDPOINT: "https://api.openai.com/v1/chat/completions", - OPENAI_ORGANIZATION: "org-", - OPENAI_MODEL: "gpt-4o, gpt-4-turbo, gpt-3.5-turbo, etc.", - OPENAI_RESPONSE_FORMAT: "1 (to enable JSON response format)", }, testFunction: testOpenAIChat, }, - - // OpenAI - Embeddings { name: "OpenAI (Embeddings)", description: "Alternative to Azure OpenAI for text embeddings", @@ -155,47 +273,9 @@ const serviceConfigs: ServiceKeyConfig[] = [ OPENAI_ENDPOINT_EMBEDDING: "https://api.openai.com/v1/embeddings", OPENAI_MODEL_EMBEDDING: "text-embedding-ada-002, text-embedding-3-small, etc.", - OPENAI_API_KEY_EMBEDDING: - "sk- (optional if OPENAI_API_KEY is set)", }, testFunction: testOpenAIEmbeddings, }, - - // Azure OpenAI - GPT-3.5 Turbo - { - name: "Azure OpenAI (GPT-3.5 Turbo)", - description: "Fast chat response and email content generation", - requiredKeys: [ - "AZURE_OPENAI_API_KEY_GPT_35_TURBO", - "AZURE_OPENAI_ENDPOINT_GPT_35_TURBO", - ], - expectedFormats: { - AZURE_OPENAI_API_KEY_GPT_35_TURBO: - "<32-character hex string> or 'identity' for keyless access", - AZURE_OPENAI_ENDPOINT_GPT_35_TURBO: - "https://.openai.azure.com/openai/deployments//chat/completions?api-version=2024-02-01", - }, - testFunction: () => testAzureOpenAIEndpoint("GPT_35_TURBO"), - }, - - // Azure OpenAI - GPT-4o - { - name: "Azure OpenAI (GPT-4o)", - description: "Browser - Crossword Page functionality", - requiredKeys: [ - "AZURE_OPENAI_API_KEY_GPT_4_O", - "AZURE_OPENAI_ENDPOINT_GPT_4_O", - ], - expectedFormats: { - AZURE_OPENAI_API_KEY_GPT_4_O: - "<32-character hex string> or 'identity' for keyless access", - AZURE_OPENAI_ENDPOINT_GPT_4_O: - "https://.openai.azure.com/openai/deployments//chat/completions?api-version=2024-02-01", - }, - testFunction: () => testAzureOpenAIEndpoint("GPT_4_O"), - }, - - // Speech SDK { name: "Azure Speech SDK", description: "Voice input for TypeAgent Shell", @@ -312,6 +392,12 @@ const serviceConfigs: ServiceKeyConfig[] = [ }, ]; +// Combine dynamic Azure OpenAI + static services +const serviceConfigs: ServiceKeyConfig[] = [ + ...discoverAzureOpenAIConfigs(), + ...staticServiceConfigs, +]; + // ============================================================================ // Test Functions // ============================================================================ @@ -329,147 +415,6 @@ async function getAzureAccessToken(): Promise { } } -async function testAzureOpenAIChat(): Promise { - const apiKey = process.env.AZURE_OPENAI_API_KEY; - const endpoint = process.env.AZURE_OPENAI_ENDPOINT; - - if (!apiKey || !endpoint) { - return { success: false, message: "Missing required keys" }; - } - - // Validate endpoint format - if ( - !endpoint.includes("openai.azure.com") && - apiKey.toLowerCase() !== "identity" - ) { - return { - success: false, - message: "Invalid endpoint format", - details: - "Endpoint should contain 'openai.azure.com' for Azure OpenAI service", - }; - } - - try { - let headers: Record; - - if (apiKey.toLowerCase() === "identity") { - const token = await getAzureAccessToken(); - if (!token) { - return { - success: false, - message: - "Failed to get Azure access token for keyless access", - details: - "Make sure you are logged in with 'az login' and have access to the resource", - }; - } - headers = { Authorization: `Bearer ${token}` }; - } else { - headers = { "api-key": apiKey }; - } - - const response = await fetch(endpoint, { - method: "POST", - headers: { - ...headers, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - messages: [{ role: "user", content: "Say 'test'" }], - max_tokens: 5, - }), - }); - - if (response.ok) { - const data = await response.json(); - return { - success: true, - message: - "Successfully connected to Azure OpenAI Chat" + - (apiKey.toLowerCase() === "identity" ? " (keyless)" : ""), - details: `Response received with ${data.usage?.total_tokens || "unknown"} tokens`, - }; - } else { - const errorText = await response.text(); - return { - success: false, - message: `API returned status ${response.status}`, - details: errorText.substring(0, 200), - }; - } - } catch (error: any) { - return { - success: false, - message: "Connection failed", - details: error.message, - }; - } -} - -async function testAzureOpenAIEmbeddings(): Promise { - const apiKey = process.env.AZURE_OPENAI_API_KEY_EMBEDDING; - const endpoint = process.env.AZURE_OPENAI_ENDPOINT_EMBEDDING; - - if (!apiKey || !endpoint) { - return { success: false, message: "Missing required keys" }; - } - - try { - let headers: Record; - - if (apiKey.toLowerCase() === "identity") { - const token = await getAzureAccessToken(); - if (!token) { - return { - success: false, - message: - "Failed to get Azure access token for keyless access", - details: - "Make sure you are logged in with 'az login' and have access to the resource", - }; - } - headers = { Authorization: `Bearer ${token}` }; - } else { - headers = { "api-key": apiKey }; - } - - const response = await fetch(endpoint, { - method: "POST", - headers: { - ...headers, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - input: "test embedding", - }), - }); - - if (response.ok) { - const data = await response.json(); - const embedding = data.data?.[0]?.embedding; - return { - success: true, - message: "Successfully generated embeddings", - details: `Embedding dimension: ${embedding?.length || "unknown"}`, - }; - } else { - const errorText = await response.text(); - return { - success: false, - message: `API returned status ${response.status}`, - details: errorText.substring(0, 200), - }; - } - } catch (error: any) { - return { - success: false, - message: "Connection failed", - details: error.message, - }; - } -} - async function testOpenAIChat(): Promise { const apiKey = process.env.OPENAI_API_KEY; const endpoint = process.env.OPENAI_ENDPOINT; @@ -572,73 +517,6 @@ async function testOpenAIEmbeddings(): Promise { } } -async function testAzureOpenAIEndpoint( - endpointSuffix: string, -): Promise { - const apiKey = process.env[`AZURE_OPENAI_API_KEY_${endpointSuffix}`]; - const endpoint = process.env[`AZURE_OPENAI_ENDPOINT_${endpointSuffix}`]; - - if (!apiKey || !endpoint) { - return { success: false, message: "Missing required keys" }; - } - - try { - let headers: Record; - - if (apiKey.toLowerCase() === "identity") { - const token = await getAzureAccessToken(); - if (!token) { - return { - success: false, - message: - "Failed to get Azure access token for keyless access", - details: - "Make sure you are logged in with 'az login' and have access to the resource", - }; - } - headers = { Authorization: `Bearer ${token}` }; - } else { - headers = { "api-key": apiKey }; - } - - const response = await fetch(endpoint, { - method: "POST", - headers: { - ...headers, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - messages: [{ role: "user", content: "Say 'test'" }], - max_tokens: 5, - }), - }); - - if (response.ok) { - const data = await response.json(); - return { - success: true, - message: - `Successfully connected to Azure OpenAI (${endpointSuffix})` + - (apiKey.toLowerCase() === "identity" ? " (keyless)" : ""), - details: `Tokens used: ${data.usage?.total_tokens || "unknown"}`, - }; - } else { - const errorText = await response.text(); - return { - success: false, - message: `API returned status ${response.status}`, - details: errorText.substring(0, 200), - }; - } - } catch (error: any) { - return { - success: false, - message: "Connection failed", - details: error.message, - }; - } -} - async function testSpeechSDK(): Promise { const key = process.env.SPEECH_SDK_KEY; const endpoint = process.env.SPEECH_SDK_ENDPOINT; @@ -670,23 +548,41 @@ async function testSpeechSDK(): Promise { } try { - // Test token endpoint - const tokenEndpoint = - endpoint || - `https://${region}.api.cognitive.microsoft.com/sts/v1.0/issuetoken`; + const isIdentity = key === "identity"; + + // Build the token issuing URL + const baseUrl = + endpoint || `https://${region}.api.cognitive.microsoft.com`; + const tokenEndpoint = baseUrl.endsWith("/") + ? `${baseUrl}sts/v1.0/issuetoken` + : `${baseUrl}/sts/v1.0/issuetoken`; + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + }; + + if (isIdentity) { + // Use Azure Identity (DefaultAzureCredential via az CLI) + const { execSync } = await import("child_process"); + const token = execSync( + 'az account get-access-token --resource "https://cognitiveservices.azure.com/" --query accessToken -o tsv', + { encoding: "utf-8" }, + ).trim(); + headers["Authorization"] = `Bearer ${token}`; + } else { + headers["Ocp-Apim-Subscription-Key"] = key; + } + const response = await fetch(tokenEndpoint, { method: "POST", - headers: { - "Ocp-Apim-Subscription-Key": key, - "Content-Type": "application/x-www-form-urlencoded", - }, + headers, }); if (response.ok) { return { success: true, message: "Successfully authenticated with Speech SDK", - details: `Region: ${region}`, + details: `Region: ${region}, Auth: ${isIdentity ? "identity" : "key"}`, }; } else { return { @@ -930,7 +826,9 @@ async function runTests() { printWarning( `Skipped - Missing required keys: ${missingKeys.join(", ")}`, ); - console.log("\n Add the following to your .env file:"); + console.log( + "\n Add the following to your config.local.yaml (see config.sample.yaml):", + ); for (const key of missingKeys) { printKeyExample(key, config.expectedFormats[key] || ""); } @@ -1012,7 +910,7 @@ async function runTests() { process.exit(1); } else if (passedCount === 0) { console.log( - `\n${colors.yellow}No services were tested. Add keys to your .env file.${colors.reset}`, + `\n${colors.yellow}No services were tested. Add keys to your config.local.yaml (see config.sample.yaml).${colors.reset}`, ); } else { console.log(