diff --git a/AZURE_CONFIG.md b/AZURE_CONFIG.md new file mode 100644 index 00000000..f17fbbf3 --- /dev/null +++ b/AZURE_CONFIG.md @@ -0,0 +1,206 @@ +# Azure OpenAI Configuration Guide + +This document explains how to configure Azure OpenAI as the default LLM provider for Dexter. + +## Overview + +Dexter uses **Azure OpenAI with Managed Identity** as the default provider. This means: +- ✅ **Production**: Uses Azure Managed Identity (no API keys needed) +- ✅ **Development**: Uses Azure CLI credentials (requires `az login`) +- ✅ **Configuration**: All settings loaded from environment variables + +## Environment Variables + +All Azure OpenAI configuration is loaded from environment variables in your `.env` file: + +```bash +# Azure OpenAI Endpoint (your Azure resource URL) +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ + +# Deployment name (as configured in Azure portal) +AZURE_OPENAI_DEPLOYMENT=gpt-5.2-chat + +# API Version (use 2024-08-01-preview if experiencing tool argument issues) +AZURE_OPENAI_API_VERSION=2025-01-01-preview + +# Azure Cognitive Services scope (usually this default value) +AZURE_OPENAI_SCOPE=https://cognitiveservices.azure.com/.default + +# Managed Identity Client ID (for production authentication) +AZURE_OPENAI_MANAGED_IDENTITY_CLIENT_ID=your-managed-identity-client-id +``` + +## Quick Setup + +### 1. Configure Environment Variables + +A `.env` file is already provided with the default Azure OpenAI configuration. + +**⚠️ Important:** All Azure OpenAI environment variables are **REQUIRED**. The application will throw an error on startup if any are missing. + +To customize for your environment: + +```bash +# Edit the existing .env file with your Azure OpenAI values +nano .env +``` + +Update these required variables: + +```bash +AZURE_OPENAI_ENDPOINT=https://YOUR-RESOURCE.openai.azure.com/ +AZURE_OPENAI_DEPLOYMENT=YOUR-DEPLOYMENT-NAME +AZURE_OPENAI_API_VERSION=2025-01-01-preview +AZURE_OPENAI_SCOPE=https://cognitiveservices.azure.com/.default +AZURE_OPENAI_MANAGED_IDENTITY_CLIENT_ID=YOUR-CLIENT-ID +``` + +**For new setups:** You can also copy from the template: +```bash +cp env.example .env +``` + +### 2. Authenticate (Development Only) + +For local development, login with Azure CLI: + +```bash +az login +``` + +This authenticates you with Azure so the application can obtain tokens on your behalf. + +## Finding Your Configuration Values + +### Azure OpenAI Endpoint + +1. Go to [Azure Portal](https://portal.azure.com) +2. Navigate to your Azure OpenAI resource +3. Click on "Keys and Endpoint" +4. Copy the "Endpoint" value (e.g., `https://your-resource.openai.azure.com/`) + +### Deployment Name + +1. In Azure Portal, go to your Azure OpenAI resource +2. Click on "Model deployments" or "Deployments" +3. Copy the name of your deployment (e.g., `gpt-5.2-chat`, `gpt-4`) + +### API Version + +- Use `2025-01-01-preview` for latest features +- Use `2024-08-01-preview` if you experience empty tool arguments bug +- Check [Azure OpenAI API versions](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference) for latest + +### Managed Identity Client ID + +1. In Azure Portal, go to "Managed Identities" +2. Find your user-assigned managed identity +3. Copy the "Client ID" value + +## Authentication Flow + +### Development Environment +``` +Application → Azure CLI Credentials → Azure AD → Azure OpenAI +``` + +- Uses `AzureCliCredential` from `@azure/identity` +- Requires `az login` to be run first +- Tokens cached for 5 minutes + +### Production Environment +``` +Application → Managed Identity → Azure AD → Azure OpenAI +``` + +- Uses `ManagedIdentityCredential` from `@azure/identity` +- No credentials needed in code or environment +- Automatic token refresh + +## Switching Providers + +To use a different LLM provider instead of Azure OpenAI: + +### OpenAI (Official API) +```bash +# Add to .env +OPENAI_API_KEY=sk-... + +# Use models with openai: prefix +bun start +# Then select model: openai:gpt-4 +``` + +### Anthropic Claude +```bash +# Add to .env +ANTHROPIC_API_KEY=sk-ant-... + +# Use models with claude- prefix (auto-detected) +bun start +# Then select model: claude-sonnet-4-5 +``` + +### Other Providers +See [README.md](README.md) for full list of supported providers. + +## Troubleshooting + +### "No valid authentication credentials" + +**Solution**: Run `az login` in your terminal + +### "Token refresh failed" + +**Solution**: +1. Check your Managed Identity has correct permissions on Azure OpenAI resource +2. Verify `AZURE_OPENAI_MANAGED_IDENTITY_CLIENT_ID` is correct +3. Ensure resource has "Cognitive Services OpenAI User" role assigned + +### "Deployment not found" + +**Solution**: +1. Verify `AZURE_OPENAI_DEPLOYMENT` matches exact deployment name in Azure +2. Check deployment is active and not paused +3. Ensure endpoint URL is correct + +### "API version not supported" + +**Solution**: Try `AZURE_OPENAI_API_VERSION=2024-08-01-preview` or check [latest versions](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference) + +### "Empty tool arguments" error + +**Solution**: Change to older API version: +```bash +AZURE_OPENAI_API_VERSION=2024-08-01-preview +``` + +## Security Best Practices + +1. ✅ **Never commit `.env` files** - they contain sensitive configuration +2. ✅ **Use Managed Identity in production** - avoid storing credentials +3. ✅ **Rotate managed identity** - if credentials are compromised +4. ✅ **Use Azure Key Vault** - for additional security layer +5. ✅ **Limit scope** - grant minimal permissions needed + +## Testing + +Run the test suite to verify your Azure OpenAI configuration: + +```bash +bun test src/agent/agent-azure-openai.test.ts +``` + +This will test: +- ✅ Authentication with managed identity +- ✅ LLM API calls +- ✅ Token usage tracking +- ✅ Agent functionality +- ✅ Concurrent requests + +## Additional Resources + +- [Azure OpenAI Documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/) +- [Azure Managed Identity](https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/) +- [Azure CLI Authentication](https://learn.microsoft.com/en-us/cli/azure/authenticate-azure-cli) +- [@azure/identity Package](https://www.npmjs.com/package/@azure/identity) diff --git a/README.md b/README.md index 70db6615..a2c1150d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ Dexter takes complex financial questions and turns them into clear, step-by-step ## ✅ Prerequisites - [Bun](https://bun.com) runtime (v1.0 or higher) -- OpenAI API key (get [here](https://platform.openai.com/api-keys)) +- Azure OpenAI access (uses managed identity - no API key needed in production) + - For local development, ensure you're logged in via Azure CLI: `az login` + - Alternatively, use OpenAI API key (get [here](https://platform.openai.com/api-keys)) - Financial Datasets API key (get [here](https://financialdatasets.ai)) - Exa API key (get [here](https://exa.ai)) - optional, for web search @@ -77,8 +79,18 @@ bun install # Copy the example environment file cp env.example .env -# Edit .env and add your API keys (if using cloud providers) -# OPENAI_API_KEY=your-openai-api-key +# Azure OpenAI Configuration (Default Provider) +# Configure your Azure OpenAI endpoint and deployment +# AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ +# AZURE_OPENAI_DEPLOYMENT=your-deployment-name +# AZURE_OPENAI_API_VERSION=2025-01-01-preview +# AZURE_OPENAI_SCOPE=https://cognitiveservices.azure.com/.default +# AZURE_OPENAI_MANAGED_IDENTITY_CLIENT_ID=your-managed-identity-client-id + +# For local development with Azure OpenAI, ensure you're logged in: az login + +# Alternative LLM Provider API Keys (optional) +# OPENAI_API_KEY=your-openai-api-key (use openai: prefix for models) # ANTHROPIC_API_KEY=your-anthropic-api-key (optional) # GOOGLE_API_KEY=your-google-api-key (optional) # XAI_API_KEY=your-xai-api-key (optional) @@ -93,6 +105,9 @@ cp env.example .env # Web Search (Exa preferred, Tavily fallback) # EXASEARCH_API_KEY=your-exa-api-key # TAVILY_API_KEY=your-tavily-api-key + +# Note: NODE_ENV=production uses Azure Managed Identity +# NODE_ENV=development uses Azure CLI credentials ``` ## 🚀 How to Run diff --git a/bun.lock b/bun.lock index 4ed29bee..beb2b0bc 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "dexter-ts", "dependencies": { + "@azure/identity": "^4.5.0", "@langchain/anthropic": "^1.1.3", "@langchain/core": "^1.1.0", "@langchain/exa": "^1.0.1", @@ -47,6 +48,28 @@ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-go1XeWXmpxuiTkosSXpb8tokLk2ZLkIRcXpbWVwJM6gH5OBtHOVsfPfGuqI1oW7RRt4qc59EmYbrXRZ0Ng06Jw=="], + "@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="], + + "@azure/core-auth": ["@azure/core-auth@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="], + + "@azure/core-client": ["@azure/core-client@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w=="], + + "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.22.2", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg=="], + + "@azure/core-tracing": ["@azure/core-tracing@1.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ=="], + + "@azure/core-util": ["@azure/core-util@1.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A=="], + + "@azure/identity": ["@azure/identity@4.13.0", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^4.2.0", "@azure/msal-node": "^3.5.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw=="], + + "@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="], + + "@azure/msal-browser": ["@azure/msal-browser@4.28.2", "", { "dependencies": { "@azure/msal-common": "15.14.2" } }, "sha512-6vYUMvs6kJxJgxaCmHn/F8VxjLHNh7i9wzfwPGf8kyBJ8Gg2yvBXx175Uev8LdrD1F5C4o7qHa2CC4IrhGE1XQ=="], + + "@azure/msal-common": ["@azure/msal-common@15.14.2", "", {}, "sha512-n8RBJEUmd5QotoqbZfd+eGBkzuFI1KX6jw2b3WcpSyGjwmzoeI/Jb99opIBPHpb8y312NB+B6+FGi2ZVSR8yfA=="], + + "@azure/msal-node": ["@azure/msal-node@3.8.7", "", { "dependencies": { "@azure/msal-common": "15.14.2", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-a+Xnrae+uwLnlw68bplS1X4kuJ9F/7K6afuMFyRkNIskhjgDezl5Fhrx+1pmAlDmC0VaaAxjRQMp1OmcqVwkIg=="], + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], @@ -513,10 +536,14 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@whiskeysockets/baileys": ["@whiskeysockets/baileys@7.0.0-rc.9", "", { "dependencies": { "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", "lru-cache": "^11.1.0", "music-metadata": "^11.7.0", "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", "ws": "^8.13.0" }, "peerDependencies": { "audio-decode": "^2.1.3", "jimp": "^1.6.0", "link-preview-js": "^3.0.0", "sharp": "*" }, "optionalPeers": ["audio-decode", "jimp", "link-preview-js"] }, "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -567,10 +594,14 @@ "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "cacheable": ["cacheable@2.3.2", "", { "dependencies": { "@cacheable/memory": "^2.0.7", "@cacheable/utils": "^2.3.3", "hookified": "^1.15.0", "keyv": "^5.5.5", "qified": "^0.6.0" } }, "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -643,6 +674,12 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], @@ -659,6 +696,8 @@ "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.263", "", {}, "sha512-DrqJ11Knd+lo+dv+lltvfMDLU27g14LMdH2b0O3Pio4uk0x+z7OR+JrmyacTPN2M8w3BrZ7/RTwG3R9B7irPlg=="], "emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="], @@ -743,6 +782,10 @@ "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -767,6 +810,8 @@ "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], @@ -775,12 +820,16 @@ "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-network-error": ["is-network-error@1.3.0", "", {}, "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw=="], "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -859,6 +908,12 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], @@ -879,8 +934,22 @@ "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + "lodash.memoize": ["lodash.memoize@4.1.2", "", {}, "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag=="], + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], @@ -933,6 +1002,8 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="], "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], @@ -1027,6 +1098,10 @@ "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1155,6 +1230,8 @@ "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1173,6 +1250,8 @@ "@alcalzone/ansi-tokenize/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], @@ -1261,6 +1340,8 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "jsonwebtoken/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "langsmith/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "libsignal/protobufjs": ["protobufjs@6.8.8", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/long": "^4.0.0", "@types/node": "^10.1.0", "long": "^4.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw=="], diff --git a/env.example b/env.example index 719d3781..79f17995 100644 --- a/env.example +++ b/env.example @@ -1,4 +1,15 @@ -# LLM API Keys +# Azure OpenAI Configuration (Default Provider) +# These settings are REQUIRED when using Azure OpenAI +# Production: uses Azure Managed Identity (no API key needed) +# Development: uses Azure CLI credentials (run: az login first) +AZURE_OPENAI_ENDPOINT=https://your-resource-name.openai.azure.com/ +AZURE_OPENAI_DEPLOYMENT=your-deployment-name +AZURE_OPENAI_API_VERSION=2025-01-01-preview +AZURE_OPENAI_SCOPE=https://cognitiveservices.azure.com/.default +AZURE_OPENAI_MANAGED_IDENTITY_CLIENT_ID=your-managed-identity-client-id + +# Optional: Alternative LLM Provider API Keys +# Use provider prefix for models when using these (e.g., "openai:gpt-4") OPENAI_API_KEY=your-api-key ANTHROPIC_API_KEY=your-api-key GOOGLE_API_KEY=your-api-key diff --git a/package.json b/package.json index e5e84ec1..92228255 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "postinstall": "playwright install chromium" }, "dependencies": { + "@azure/identity": "^4.5.0", "@langchain/anthropic": "^1.1.3", "@langchain/core": "^1.1.0", "@langchain/exa": "^1.0.1", diff --git a/src/agent/agent-azure-openai.test.ts b/src/agent/agent-azure-openai.test.ts new file mode 100644 index 00000000..deb97847 --- /dev/null +++ b/src/agent/agent-azure-openai.test.ts @@ -0,0 +1,277 @@ +import { describe, test, expect, beforeAll } from 'bun:test'; +import { Agent } from './agent.js'; +import { callLlm, getChatModel, DEFAULT_MODEL, DEFAULT_PROVIDER } from '../model/llm.js'; +import type { AIMessage } from '@langchain/core/messages'; + +describe('Azure OpenAI Integration', () => { + beforeAll(() => { + // Ensure we're using Azure OpenAI + expect(DEFAULT_PROVIDER).toBe('azureopenai'); + expect(DEFAULT_MODEL).toBe('gpt-5.2'); + }); + + describe('LLM Direct Calls', () => { + test('should use Azure OpenAI with managed identity by default', () => { + const model = getChatModel(); + expect(model).toBeDefined(); + expect(model.constructor.name).toBe('ChatOpenAI'); + }); + + test('should successfully call Azure OpenAI with a simple prompt', async () => { + const result = await callLlm('Say "Hello from Azure OpenAI" and nothing else.', { + model: DEFAULT_MODEL, + }); + + expect(result).toBeDefined(); + expect(result.response).toBeDefined(); + + const responseText = typeof result.response === 'string' + ? result.response + : (result.response as AIMessage).content; + + expect(typeof responseText).toBe('string'); + expect((responseText as string).length).toBeGreaterThan(0); + + console.log('✅ Azure OpenAI Response: - agent-azure-openai.test.ts:35', responseText); + }, { timeout: 30000 }); + + test('should return token usage information', async () => { + const result = await callLlm('Count to 3.', { + model: DEFAULT_MODEL, + }); + + expect(result.usage).toBeDefined(); + if (result.usage) { + expect(result.usage.inputTokens).toBeGreaterThan(0); + expect(result.usage.outputTokens).toBeGreaterThan(0); + expect(result.usage.totalTokens).toBeGreaterThan(0); + + console.log('✅ Token Usage: - agent-azure-openai.test.ts:49', result.usage); + } + }, { timeout: 30000 }); + + test('should handle multi-turn conversation context', async () => { + const systemPrompt = 'You are a helpful assistant. Be concise.'; + + const result = await callLlm('What is 2+2?', { + model: DEFAULT_MODEL, + systemPrompt, + }); + + expect(result.response).toBeDefined(); + const responseText = typeof result.response === 'string' + ? result.response + : (result.response as AIMessage).content; + + expect(responseText).toContain('4'); + console.log('✅ Math Response: - agent-azure-openai.test.ts:67', responseText); + }, { timeout: 30000 }); + }); + + describe('Agent Integration', () => { + test('should create agent with default Azure OpenAI configuration', () => { + const agent = Agent.create({ model: DEFAULT_MODEL }); + expect(agent).toBeDefined(); + console.log('✅ Agent created successfully with Azure OpenAI - agent-azure-openai.test.ts:75'); + }); + + test('should handle simple conversational query without tool calls', async () => { + const agent = Agent.create({ + model: DEFAULT_MODEL, + maxIterations: 5, + }); + + const query = 'What features do you have? Please list your key capabilities.'; + const events: string[] = []; + let finalAnswer = ''; + + console.log('\n🤖 Testing Agent with query: - agent-azure-openai.test.ts:88', query); + console.log('━ - agent-azure-openai.test.ts:89'.repeat(80)); + + for await (const event of agent.run(query)) { + events.push(event.type); + + switch (event.type) { + case 'thinking': + console.log('💭 Thinking: - agent-azure-openai.test.ts:96', event.message); + break; + + case 'tool_start': + console.log(`🔧 Tool Start: ${event.tool} - agent-azure-openai.test.ts:100`); + break; + + case 'tool_end': + console.log(`✅ Tool End: ${event.tool} - agent-azure-openai.test.ts:104`); + break; + + case 'answer_start': + console.log('📝 Generating final answer... - agent-azure-openai.test.ts:108'); + break; + + case 'done': + finalAnswer = event.answer; + console.log('\n✨ Final Answer: - agent-azure-openai.test.ts:113'); + console.log('━ - agent-azure-openai.test.ts:114'.repeat(80)); + console.log(finalAnswer); + console.log('━ - agent-azure-openai.test.ts:116'.repeat(80)); + console.log(`\n📊 Stats: - agent-azure-openai.test.ts:117`); + console.log(`Iterations: ${event.iterations} - agent-azure-openai.test.ts:118`); + console.log(`Tool Calls: ${event.toolCalls.length} - agent-azure-openai.test.ts:119`); + console.log(`Time: ${event.totalTime}ms - agent-azure-openai.test.ts:120`); + if (event.tokenUsage) { + console.log(`Tokens: ${event.tokenUsage.totalTokens} (${event.tokenUsage.inputTokens} in, ${event.tokenUsage.outputTokens} out) - agent-azure-openai.test.ts:122`); + } + break; + } + } + + // Assertions + expect(events).toContain('done'); + expect(finalAnswer).toBeDefined(); + expect(finalAnswer.length).toBeGreaterThan(0); + + // The response should mention some capabilities/features + const lowerAnswer = finalAnswer.toLowerCase(); + const hasFeaturesMention = + lowerAnswer.includes('feature') || + lowerAnswer.includes('capabilit') || + lowerAnswer.includes('can') || + lowerAnswer.includes('tool') || + lowerAnswer.includes('help') || + lowerAnswer.includes('research') || + lowerAnswer.includes('financial') || + lowerAnswer.includes('data'); + + expect(hasFeaturesMention).toBe(true); + + console.log('\n✅ Agent test completed successfully! - agent-azure-openai.test.ts:147'); + }, { timeout: 60000 }); + + test('should handle tool-based queries', async () => { + const agent = Agent.create({ + model: DEFAULT_MODEL, + maxIterations: 10, + }); + + const query = 'What tools are available to you? Just list them, don\'t use them.'; + let finalAnswer = ''; + let toolCallCount = 0; + + console.log('\n🤖 Testing Agent with query: - agent-azure-openai.test.ts:160', query); + console.log('━ - agent-azure-openai.test.ts:161'.repeat(80)); + + for await (const event of agent.run(query)) { + if (event.type === 'tool_start') { + toolCallCount++; + } + + if (event.type === 'done') { + finalAnswer = event.answer; + console.log('\n✨ Final Answer: - agent-azure-openai.test.ts:170', finalAnswer); + console.log(`📊 Tool Calls: ${toolCallCount} - agent-azure-openai.test.ts:171`); + } + } + + expect(finalAnswer).toBeDefined(); + expect(finalAnswer.length).toBeGreaterThan(0); + + console.log('\n✅ Toolbased query test completed! - agent-azure-openai.test.ts:178'); + }, { timeout: 60000 }); + + test('should respect max iterations limit', async () => { + const agent = Agent.create({ + model: DEFAULT_MODEL, + maxIterations: 2, // Very low limit + }); + + const query = 'Tell me about yourself'; + let iterations = 0; + + for await (const event of agent.run(query)) { + if (event.type === 'done') { + iterations = event.iterations; + } + } + + expect(iterations).toBeLessThanOrEqual(2); + console.log(`✅ Max iterations respected: ${iterations}/2 - agent-azure-openai.test.ts:197`); + }, { timeout: 30000 }); + }); + + describe('Error Handling', () => { + test('should handle empty query gracefully', async () => { + const agent = Agent.create({ model: DEFAULT_MODEL }); + let hasError = false; + let finalAnswer = ''; + + try { + for await (const event of agent.run('')) { + if (event.type === 'done') { + finalAnswer = event.answer; + } + } + } catch (error) { + hasError = true; + } + + // Should either handle gracefully with a response or throw an error + expect(hasError || finalAnswer.length > 0).toBe(true); + console.log('✅ Empty query handled: - agent-azure-openai.test.ts:219', hasError ? 'with error' : 'gracefully'); + }, { timeout: 30000 }); + }); + + describe('Azure Managed Identity Configuration', () => { + test('should use correct credential type based on environment', () => { + const isProduction = process.env.NODE_ENV === 'production'; + const expectedCredentialType = isProduction ? 'ManagedIdentityCredential' : 'AzureCliCredential'; + + console.log(`✅ Environment: ${process.env.NODE_ENV || 'development'} - agent-azure-openai.test.ts:228`); + console.log(`✅ Expected Credential: ${expectedCredentialType} - agent-azure-openai.test.ts:229`); + + // This test mainly documents the expected behavior + // In test environment, it should use AzureCliCredential (same as development) + expect(['production', 'development', 'test', undefined]).toContain(process.env.NODE_ENV); + }); + }); +}); + +describe('Azure OpenAI Performance', () => { + test('should complete simple query within reasonable time', async () => { + const startTime = Date.now(); + + const result = await callLlm('Say hi!', { + model: DEFAULT_MODEL, + }); + + const duration = Date.now() - startTime; + + expect(result.response).toBeDefined(); + expect(duration).toBeLessThan(15000); // Should complete within 15 seconds + + console.log(`✅ Query completed in ${duration}ms - agent-azure-openai.test.ts:251`); + }, { timeout: 30000 }); + + test('should handle concurrent requests', async () => { + const queries = [ + 'What is 1+1?', + 'What is 2+2?', + 'What is 3+3?', + ]; + + const startTime = Date.now(); + + const results = await Promise.all( + queries.map(query => callLlm(query, { model: DEFAULT_MODEL })) + ); + + const duration = Date.now() - startTime; + + expect(results).toHaveLength(3); + results.forEach(result => { + expect(result.response).toBeDefined(); + }); + + console.log(`✅ ${queries.length} concurrent requests completed in ${duration}ms - agent-azure-openai.test.ts:274`); + console.log(`Average: ${Math.round(duration / queries.length)}ms per request - agent-azure-openai.test.ts:275`); + }, { timeout: 45000 }); +}); diff --git a/src/model/azure-openai-models.ts b/src/model/azure-openai-models.ts new file mode 100644 index 00000000..b3e82bba --- /dev/null +++ b/src/model/azure-openai-models.ts @@ -0,0 +1,51 @@ + + +// Helper function to get required environment variable +function getRequiredEnv(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error( + `Missing required environment variable: ${key}\n` + + `Please set it in your .env file. See env.example for reference.` + ); + } + return value; +} + +// Azure OpenAI configuration loaded from environment variables +// All values are required - no fallback defaults +export const AZURE_OPENAI_ENDPOINT = getRequiredEnv('AZURE_OPENAI_ENDPOINT'); +export const AZURE_OPENAI_DEPLOYMENT = getRequiredEnv('AZURE_OPENAI_DEPLOYMENT'); +export const AZURE_OPENAI_API_VERSION = getRequiredEnv('AZURE_OPENAI_API_VERSION'); +export const AZURE_OPENAI_SCOPE = getRequiredEnv('AZURE_OPENAI_SCOPE'); +export const AZURE_OPENAI_MANAGED_IDENTITY_CLIENT_ID = getRequiredEnv('AZURE_OPENAI_MANAGED_IDENTITY_CLIENT_ID'); + + + +// Initialize the DefaultAzureCredential + +// Default model configuration +export const AZURE_OPENAI_DEFAULT_MODEL_ID = "gpt-5.2"; +export const AZURE_OPENAI_DEFAULT_MODEL_REF = `azureopenai/${AZURE_OPENAI_DEFAULT_MODEL_ID}`; + +export const AZURE_OPENAI_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export const AZURE_OPENAI_MODEL_CATALOG = [ + { + id: AZURE_OPENAI_DEFAULT_MODEL_ID, + name: "GPT-5.2", + reasoning: false, + input: ["text", "image"] as const, + contextWindow: 128000, + maxTokens: 8192, + }, +] as const; + +export type AzureOpenAICatalogEntry = (typeof AZURE_OPENAI_MODEL_CATALOG)[number]; + + diff --git a/src/model/llm.ts b/src/model/llm.ts index 31bc71af..438314ba 100644 --- a/src/model/llm.ts +++ b/src/model/llm.ts @@ -13,8 +13,16 @@ import { DEFAULT_SYSTEM_PROMPT } from '@/agent/prompts'; import type { TokenUsage } from '@/agent/types'; import { logger } from '@/utils'; import { resolveProvider, getProviderById } from '@/providers'; +import { AzureCliCredential, ManagedIdentityCredential } from '@azure/identity'; +import { + AZURE_OPENAI_ENDPOINT, + AZURE_OPENAI_DEPLOYMENT, + AZURE_OPENAI_API_VERSION, + AZURE_OPENAI_SCOPE, + AZURE_OPENAI_MANAGED_IDENTITY_CLIENT_ID, +} from './azure-openai-models.js'; -export const DEFAULT_PROVIDER = 'openai'; +export const DEFAULT_PROVIDER = 'azureopenai'; export const DEFAULT_MODEL = 'gpt-5.2'; /** @@ -58,8 +66,67 @@ function getApiKey(envVar: string): string { return apiKey; } +// Helper to get Azure credential for managed identity +function getAzureCredential() { + const credential = process.env.NODE_ENV === 'production' + ? new ManagedIdentityCredential(AZURE_OPENAI_MANAGED_IDENTITY_CLIENT_ID) + : new AzureCliCredential(); + + logger.info(`[Azure OpenAI] Using ${process.env.NODE_ENV === 'production' ? 'ManagedIdentityCredential' : 'AzureCliCredential'}`); + + return credential; +} + +// Cache for Azure token to avoid fetching on every request +let cachedAzureToken: { token: string; expiresAt: number } | null = null; + +async function getAzureToken(): Promise { + // Return cached token if still valid (with 5 min buffer) + if (cachedAzureToken && Date.now() < cachedAzureToken.expiresAt - 5 * 60 * 1000) { + return cachedAzureToken.token; + } + + const credential = getAzureCredential(); + const tokenResponse = await credential.getToken(AZURE_OPENAI_SCOPE); + + cachedAzureToken = { + token: tokenResponse.token, + expiresAt: tokenResponse.expiresOnTimestamp, + }; + + logger.info('[Azure OpenAI] Token refreshed'); + return tokenResponse.token; +} + // Factories keyed by provider id — prefix routing is handled by resolveProvider() const MODEL_FACTORIES: Record = { + azureopenai: (name, opts) => { + // Use a custom fetch that injects the Azure AD token + const customFetch = async (input: string | URL | Request, init?: RequestInit) => { + const token = await getAzureToken(); + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${token}`); + headers.set('api-key', token); + + return fetch(input, { + ...init, + headers, + }); + }; + + return new ChatOpenAI({ + model: name, + ...opts, + temperature: 1, + // Use empty API key since we're providing auth via custom fetch + apiKey: 'managed-identity', + configuration: { + baseURL: `${AZURE_OPENAI_ENDPOINT}openai/deployments/${AZURE_OPENAI_DEPLOYMENT}`, + defaultQuery: { 'api-version': AZURE_OPENAI_API_VERSION }, + fetch: customFetch, + }, + }); + }, anthropic: (name, opts) => new ChatAnthropic({ model: name, diff --git a/src/providers.ts b/src/providers.ts index 8fb2bb82..25e0ac53 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -17,10 +17,17 @@ export interface ProviderDef { } export const PROVIDERS: ProviderDef[] = [ + { + id: 'azureopenai', + displayName: 'Azure OpenAI', + modelPrefix: '', + // No API key needed - uses Azure Managed Identity + fastModel: 'gpt-5.2', + }, { id: 'openai', displayName: 'OpenAI', - modelPrefix: '', + modelPrefix: 'openai:', apiKeyEnvVar: 'OPENAI_API_KEY', fastModel: 'gpt-4.1', }, @@ -73,7 +80,7 @@ export const PROVIDERS: ProviderDef[] = [ }, ]; -const defaultProvider = PROVIDERS.find((p) => p.id === 'openai')!; +const defaultProvider = PROVIDERS.find((p) => p.id === 'azureopenai')!; /** * Resolve the provider for a given model name based on its prefix. diff --git a/src/utils/config.ts b/src/utils/config.ts index 1eafa603..098bb366 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -5,7 +5,7 @@ const SETTINGS_FILE = '.dexter/settings.json'; // Map legacy model IDs to provider IDs for migration const MODEL_TO_PROVIDER_MAP: Record = { - 'gpt-5.2': 'openai', + 'gpt-5.2': 'azureopenai', 'claude-sonnet-4-5': 'anthropic', 'gemini-3': 'google', };