From 283a548e051f6d0eb559013220cfb27d9e09bd6a Mon Sep 17 00:00:00 2001 From: Justin Lecher Date: Mon, 23 Feb 2026 11:31:29 +0000 Subject: [PATCH] feat: address PR #2014 feedback and add configurable Azure OpenAI API version This commit addresses all review feedback from PR #2014 and adds configurable API version support for Azure OpenAI backend. PR Review Fixes: - Add URL validation with HTTPS enforcement (ISC-C7) - Implement cancellable context in SendStream with 300s timeout (ISC-C6) - Add response body size limit (10MB) using io.LimitReader (ISC-C12) - Update error body truncation from 200 to 500 characters (ISC-C13) - Lowercase error messages per Go convention (ISC-C15) - Add file-level documentation to all backend files (ISC-C14) - Fix Bedrock max_tokens to respect opts.MaxTokens with fallback (ISC-C5) - Document temperature/top_p mutual exclusivity in Bedrock (ISC-C11) - Add debug logging when empty messages are skipped (ISC-C16) - Add error check for empty message lists across all backends - Verify and document Vertex AI endpoint path for APIM routing (ISC-C9) - Update Azure OpenAI API version to 2025-04-01-preview (ISC-C10) New Feature - Configurable API Version: - Add APIVersion field to Client struct for Azure OpenAI backend - Add optional setup question for API version (default: 2025-04-01-preview) - Update AzureOpenAIBackend to accept and use configurable API version - Maintain backward compatibility: empty version defaults to 2025-04-01-preview - Add test coverage for custom API version and backward compatibility - Update all existing tests to work with new backend signature Test Coverage: 89.1% (maintained from 89.0%) All 52 tests passing (49 existing + 3 new API version tests) Internationalization polish: add AzureAIGateway locale strings across all 10 languages - Add `azureaigateway_*` i18n keys to all 10 locale files - Replace hardcoded error strings with `i18n.T()` calls - Internationalize setup question prompts for gateway configuration - Add `errors.New` in place of `fmt.Errorf` for static error strings - Fix `url.QueryEscape` for API version query parameter encoding - Correct `claude-opus-4-6-v1` model ID to include `:0` suffix - Add error case for empty Bedrock text content blocks in `ParseResponse` - Add test for Bedrock `ParseResponse` with no text content blocks - Add `SendStream` context limitation note as inline code comment - Truncate debug error body log output at 2000 characters --- cmd/generate_changelog/incoming/2021.txt | 7 + docs/Azure-AI-Gateway.md | 346 ++++++++++++++++ internal/i18n/locales/de.json | 22 + internal/i18n/locales/en.json | 22 + internal/i18n/locales/es.json | 22 + internal/i18n/locales/fa.json | 22 + internal/i18n/locales/fr.json | 22 + internal/i18n/locales/it.json | 22 + internal/i18n/locales/ja.json | 22 + internal/i18n/locales/pt-BR.json | 22 + internal/i18n/locales/pt-PT.json | 22 + internal/i18n/locales/zh.json | 22 + .../ai/azureaigateway/azureaigateway.go | 65 +-- .../ai/azureaigateway/azureaigateway_test.go | 376 +++++++++++++++++- .../ai/azureaigateway/backend_azure_openai.go | 29 +- .../ai/azureaigateway/backend_bedrock.go | 23 +- .../ai/azureaigateway/backend_vertex_ai.go | 15 +- 17 files changed, 1029 insertions(+), 52 deletions(-) create mode 100644 cmd/generate_changelog/incoming/2021.txt create mode 100644 docs/Azure-AI-Gateway.md diff --git a/cmd/generate_changelog/incoming/2021.txt b/cmd/generate_changelog/incoming/2021.txt new file mode 100644 index 00000000..03157dc7 --- /dev/null +++ b/cmd/generate_changelog/incoming/2021.txt @@ -0,0 +1,7 @@ +### PR [#2021](https://github.com/danielmiessler/Fabric/pull/2021) by [jlec](https://github.com/jlec) and [ksylvan](https://github.com/ksylvan): Enhanced Azure AI Gateway with i18n Support and documentation + +- Added configurable API version support for the Azure OpenAI backend, defaulting to `2025-04-01-preview` while maintaining backward compatibility. +- Implemented URL validation with HTTPS enforcement and a cancellable context in `SendStream` with a 300-second timeout. +- Added a 10MB response body size limit using `io.LimitReader` and improved error body truncation from 200 to 500 characters. +- Added file-level documentation across all backend files and enforced lowercase error messages per Go convention. +- Fixed Bedrock `max_tokens` to correctly respect `opts.MaxTokens` with a fallback, and added error checks for empty message lists across all backends. diff --git a/docs/Azure-AI-Gateway.md b/docs/Azure-AI-Gateway.md new file mode 100644 index 00000000..416f511e --- /dev/null +++ b/docs/Azure-AI-Gateway.md @@ -0,0 +1,346 @@ +# Azure AI Gateway Plugin + +The Azure AI Gateway plugin enables Fabric to access multiple AI providers through a single Azure API Management (APIM) Gateway endpoint. This allows organizations using Azure APIM as a central AI gateway to leverage Fabric with any supported backend provider using a single subscription key. + +## Overview + +Azure AI Gateway acts as a unified proxy that routes requests to different AI providers: + +- **AWS Bedrock** - Claude models via Bedrock inference profiles +- **Azure OpenAI** - GPT-4o, GPT-4 Turbo, o1, DeepSeek-R1 models +- **Google Vertex AI** - Gemini model family + +All backends share the same authentication mechanism (Azure APIM subscription key) and gateway endpoint, simplifying credential management and access control. + +## Prerequisites + +1. **Azure APIM Gateway** - A configured Azure API Management instance +2. **Gateway Subscription Key** - APIM subscription key with access to AI backends +3. **Backend Access** - Your APIM gateway must be configured to proxy to at least one of: + - AWS Bedrock (requires AWS credentials configured in APIM) + - Azure OpenAI (requires Azure OpenAI deployment) + - Google Vertex AI (requires GCP credentials configured in APIM) + +## Configuration + +Run `fabric --setup` and select `AzureAIGateway` from the vendor list. + +You'll be prompted for: + +### Required Fields + +1. **Backend Type** (`backend`) + - Options: `bedrock`, `azure-openai`, `vertex-ai` + - Default: `bedrock` + - Choose based on which AI provider your APIM gateway is configured to access + +2. **Gateway URL** (`gateway_url`) + - Your Azure APIM Gateway base URL + - Example: `https://gateway.company.com` + - Must use HTTPS + +3. **Subscription Key** (`subscription_key`) + - Your Azure APIM subscription key + - Used for authentication to the gateway + +### Optional Fields + +4. **API Version** (`api_version`) - **Azure OpenAI backend only** + - Azure OpenAI API version to use + - Default: `2025-04-01-preview` + - Leave empty to use the default + - Custom versions: `2024-08-01-preview`, `2024-10-21`, etc. + - See [Azure OpenAI API Reference](https://learn.microsoft.com/azure/ai-services/openai/reference) + +## Backend-Specific Configuration + +### AWS Bedrock + +**Authentication:** `Authorization: Bearer ` + +**API Format:** Anthropic Messages API + +**Models:** +```bash +fabric --listmodels +# Returns Claude models available via Bedrock: +# - us.anthropic.claude-3-5-sonnet-20241022-v2:0 +# - us.anthropic.claude-3-5-haiku-20241022-v1:0 +# - us.anthropic.claude-3-opus-20240229-v1:0 +# - etc. +``` + +**Endpoint Pattern:** `/model/{model-id}/invoke` + +**Configuration:** +```bash +fabric --setup +# Select: AzureAIGateway +# Backend: bedrock +# Gateway URL: https://gateway.company.com +# Subscription Key: your-apim-key +``` + +### Azure OpenAI + +**Authentication:** `api-key: ` + +**API Format:** OpenAI Chat Completions API + +**Models:** +```bash +fabric --listmodels +# Returns Azure OpenAI deployment names: +# - gpt-4o +# - gpt-4o-mini +# - gpt-4-turbo +# - gpt-35-turbo +# - o1 +# - o1-mini +# - DeepSeek-R1 +``` + +**Endpoint Pattern:** `/openai/deployments/{deployment-name}/chat/completions?api-version={version}` + +**Configuration:** +```bash +fabric --setup +# Select: AzureAIGateway +# Backend: azure-openai +# Gateway URL: https://gateway.company.com +# Subscription Key: your-apim-key +# API Version: (press Enter for default 2025-04-01-preview) +``` + +**Custom API Version:** +```bash +# During setup, specify a custom version: +# API Version: 2024-10-21 +``` + +### Google Vertex AI + +**Authentication:** `x-goog-api-key: ` + +**API Format:** Gemini API + +**Models:** +```bash +fabric --listmodels +# Returns Gemini models: +# - gemini-2.0-flash-exp +# - gemini-1.5-pro +# - gemini-1.5-flash +# - gemini-pro +# - gemini-pro-vision +``` + +**Endpoint Pattern:** `/publishers/google/models/{model-id}:generateContent` + +**Note:** The endpoint path differs from direct Vertex AI API (`/v1beta/models/...`) because Azure APIM Gateway uses publisher-based routing. + +**Configuration:** +```bash +fabric --setup +# Select: AzureAIGateway +# Backend: vertex-ai +# Gateway URL: https://gateway.company.com +# Subscription Key: your-apim-key +``` + +## Usage Examples + +### Basic Usage + +```bash +# Bedrock (Claude) +echo "Explain quantum computing" | fabric --model us.anthropic.claude-3-5-sonnet-20241022-v2:0 --pattern explain + +# Azure OpenAI (GPT-4o) +echo "Explain quantum computing" | fabric --model gpt-4o --pattern explain + +# Vertex AI (Gemini) +echo "Explain quantum computing" | fabric --model gemini-2.0-flash-exp --pattern explain +``` + +### Using Patterns + +```bash +# Extract wisdom from a YouTube video (Bedrock) +fabric --youtube "https://youtube.com/watch?v=example" --model us.anthropic.claude-3-5-sonnet-20241022-v2:0 --pattern extract_wisdom + +# Summarize an article (Azure OpenAI) +curl -s https://example.com/article | fabric --model gpt-4o --pattern summarize + +# Create content from a prompt (Vertex AI) +fabric --model gemini-1.5-pro --pattern write_essay --stream +``` + +### Switching Between Backends + +```bash +# Reconfigure to use a different backend +fabric --setup +# Select: AzureAIGateway +# Change Backend from bedrock to azure-openai +``` + +## Troubleshooting + +### Authentication Errors (401 Unauthorized) + +**Symptom:** `Request failed with status 401` + +**Causes:** +- Invalid subscription key +- Subscription key doesn't have access to the selected backend +- APIM subscription expired or disabled + +**Solutions:** +1. Verify subscription key in Azure Portal → APIM → Subscriptions +2. Check subscription scope includes your gateway API +3. Regenerate key if compromised: `fabric --setup` and enter new key + +### Model Not Found (404) + +**Symptom:** `Request failed with status 404` + +**Causes:** +- **Bedrock:** Model ID doesn't match inference profile names +- **Azure OpenAI:** Deployment name doesn't exist in your Azure OpenAI resource +- **Vertex AI:** Model not available in your region + +**Solutions:** +1. List available models: `fabric --listmodels` +2. **Azure OpenAI:** Verify deployment exists in Azure Portal +3. **Bedrock:** Use full inference profile IDs (e.g., `us.anthropic.claude-3-5-sonnet-20241022-v2:0`) + +### Connection Errors + +**Symptom:** `connection refused` or timeout errors + +**Causes:** +- Gateway URL incorrect or unreachable +- Network/firewall blocking access +- APIM gateway down + +**Solutions:** +1. Verify gateway URL is accessible: `curl -I https://gateway.company.com` +2. Check APIM gateway health in Azure Portal +3. Verify HTTPS scheme (HTTP is rejected) + +### API Version Errors (Azure OpenAI) + +**Symptom:** `API version not supported` or `400 Bad Request` + +**Causes:** +- Custom API version not supported by your APIM gateway +- Version mismatch between Azure OpenAI deployment and API version + +**Solutions:** +1. Use default version: `fabric --setup` and leave API version empty +2. Check supported versions: [Azure OpenAI API Versions](https://learn.microsoft.com/azure/ai-services/openai/reference) +3. Update APIM gateway to support newer API versions + +### Backend Mismatch + +**Symptom:** Unexpected response format or empty responses + +**Causes:** +- Selected backend doesn't match APIM gateway configuration +- Using wrong model names for the backend + +**Solutions:** +1. Reconfigure: `fabric --setup` and select correct backend type +2. **Bedrock:** Use Bedrock inference profile IDs +3. **Azure OpenAI:** Use deployment names from your Azure OpenAI resource +4. **Vertex AI:** Use Gemini model IDs + +### Request Timeout + +**Symptom:** Request times out after 5 minutes + +**Causes:** +- Model inference taking too long +- APIM gateway timeout settings +- Network latency + +**Solutions:** +1. Use faster models (e.g., Claude 3.5 Haiku, gpt-4o-mini, gemini-1.5-flash) +2. Reduce input size or complexity +3. Check APIM gateway timeout policies + +## Limitations + +### No Streaming Support + +Azure APIM Gateway doesn't support Server-Sent Events (SSE) pass-through for streaming responses. The plugin automatically falls back to buffered responses. + +**Impact:** `--stream` flag is ignored; full response is returned after model completes. + +**Workaround:** None - this is an APIM Gateway architectural limitation. + +### Request Timeout + +Maximum request timeout is 300 seconds (5 minutes). Long-running model inference may timeout. + +**Solutions:** +- Use faster models +- Reduce prompt complexity +- Configure APIM gateway timeout policies (requires gateway admin access) + +### Model Name Requirements + +Model names must exactly match backend expectations: + +- **Bedrock:** Full inference profile IDs (e.g., `us.anthropic.claude-3-5-sonnet-20241022-v2:0`) +- **Azure OpenAI:** Exact deployment names configured in your Azure OpenAI resource +- **Vertex AI:** Gemini model IDs (e.g., `gemini-2.0-flash-exp`) + +Use `fabric --listmodels` to see available options for your configured backend. + +## Security Considerations + +### Subscription Key Protection + +- **Never commit** subscription keys to version control +- Use Fabric's secure configuration storage (keys stored in `~/.config/fabric/.env`) +- Rotate keys regularly via Azure Portal + +### HTTPS Enforcement + +The plugin rejects HTTP gateway URLs to prevent plaintext credential transmission. Your gateway URL must use HTTPS. + +### Response Size Limits + +Responses are limited to 10MB to prevent memory exhaustion attacks. This is sufficient for all normal AI model responses. + +## Advanced Configuration + +### Multiple Gateway Configurations + +To use multiple APIM gateways or backends, run `fabric --setup` and reconfigure when switching contexts. + +### Environment Variables + +Fabric configuration is stored in `~/.config/fabric/.env`. Manual editing is supported but not recommended: + +```bash +# Example configuration +AZURE_AI_GATEWAY_BACKEND=bedrock +AZURE_AI_GATEWAY_GATEWAY_URL=https://gateway.company.com +AZURE_AI_GATEWAY_SUBSCRIPTION_KEY=your-key-here +AZURE_AI_GATEWAY_API_VERSION=2025-04-01-preview +``` + +## Support + +For issues specific to the Azure AI Gateway plugin: +1. Check this documentation first +2. Verify APIM gateway configuration in Azure Portal +3. Test direct APIM gateway access with curl +4. File issues at: https://github.com/danielmiessler/fabric/issues + +For APIM gateway configuration issues, consult: +- [Azure APIM GenAI Gateway Capabilities](https://learn.microsoft.com/azure/api-management/genai-gateway-capabilities) +- [Azure API Management Documentation](https://learn.microsoft.com/azure/api-management/) diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index 23bebed5..cfb86311 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -26,6 +26,28 @@ "azure_failed_extract_deployment": "Bereitstellungsname konnte nicht aus der Anfrage extrahiert werden", "azure_model_field_empty": "Modellfeld ist im Anfragekörper leer", "azure_request_body_nil": "Anfragekörper ist nil", + "azureaigateway_aoai_no_choices": "keine Auswahlmöglichkeiten in der Azure OpenAI-Antwort", + "azureaigateway_aoai_parse_response_failed": "Azure OpenAI-Antwort konnte nicht analysiert werden: %w", + "azureaigateway_api_version_question": "Azure OpenAI API-Version (Standard: 2025-04-01-preview, leer lassen für Standard)", + "azureaigateway_backend_not_initialized": "Backend nicht initialisiert - führen Sie 'fabric --setup' zur Konfiguration aus", + "azureaigateway_backend_type_question": "Backend-Typ auswählen (bedrock, azure-openai, vertex-ai)", + "azureaigateway_bedrock_no_text_blocks": "keine Text-Inhaltsblöcke in der Bedrock-Antwort", + "azureaigateway_bedrock_parse_response_failed": "Bedrock-Antwort konnte nicht analysiert werden: %w", + "azureaigateway_failed_create_request": "AzureAIGateway: Anfrage konnte nicht erstellt werden: %w", + "azureaigateway_failed_read_response": "AzureAIGateway: Antwort konnte nicht gelesen werden: %w", + "azureaigateway_gateway_url_https_required": "Gateway-URL muss HTTPS-Schema verwenden", + "azureaigateway_gateway_url_question": "Geben Sie Ihre Azure APIM Gateway-Basis-URL ein (z.B. https://gateway.firma.com)", + "azureaigateway_gateway_url_required": "Azure APIM Gateway-URL ist erforderlich", + "azureaigateway_http_error": "AzureAIGateway: HTTP %d: %s", + "azureaigateway_http_request_failed": "AzureAIGateway: HTTP-Anfrage fehlgeschlagen: %w", + "azureaigateway_invalid_gateway_url": "ungültige Gateway-URL: %w", + "azureaigateway_no_valid_messages": "keine gültigen Nachrichten nach Filterung leerer Inhalte", + "azureaigateway_prepare_request_failed": "AzureAIGateway: %w", + "azureaigateway_subscription_key_question": "Geben Sie Ihren Azure APIM-Abonnementschlüssel ein", + "azureaigateway_subscription_key_required": "Azure APIM-Abonnementschlüssel ist erforderlich", + "azureaigateway_unsupported_backend": "nicht unterstütztes Backend: %s (gültige Optionen: bedrock, azure-openai, vertex-ai)", + "azureaigateway_vertexai_no_content": "kein Inhalt in der Vertex AI-Antwort", + "azureaigateway_vertexai_parse_response_failed": "Vertex AI-Antwort konnte nicht analysiert werden: %w", "background_type_help": "Hintergrundtyp: opaque, transparent (Standard: opaque, nur für PNG/WebP)", "bedrock_aws_region_label": "AWS-Region", "bedrock_converse_failed": "Bedrock Converse für Modell %s fehlgeschlagen: %w", diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 2a906b1a..fab24f6a 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -26,6 +26,28 @@ "azure_failed_extract_deployment": "failed to extract deployment name from request", "azure_model_field_empty": "model field is empty in request body", "azure_request_body_nil": "request body is nil", + "azureaigateway_aoai_no_choices": "no choices in Azure OpenAI response", + "azureaigateway_aoai_parse_response_failed": "failed to parse Azure OpenAI response: %w", + "azureaigateway_api_version_question": "Azure OpenAI API version (default: 2025-04-01-preview, leave empty for default)", + "azureaigateway_backend_not_initialized": "backend not initialized - run 'fabric --setup' to configure", + "azureaigateway_backend_type_question": "Select backend type (bedrock, azure-openai, vertex-ai)", + "azureaigateway_bedrock_no_text_blocks": "no text content blocks in Bedrock response", + "azureaigateway_bedrock_parse_response_failed": "failed to parse Bedrock response: %w", + "azureaigateway_failed_create_request": "AzureAIGateway: failed to create request: %w", + "azureaigateway_failed_read_response": "AzureAIGateway: failed to read response: %w", + "azureaigateway_gateway_url_https_required": "gateway URL must use HTTPS scheme", + "azureaigateway_gateway_url_question": "Enter your Azure APIM Gateway base URL (e.g., https://gateway.company.com)", + "azureaigateway_gateway_url_required": "azure APIM gateway URL is required", + "azureaigateway_http_error": "AzureAIGateway: HTTP %d: %s", + "azureaigateway_http_request_failed": "AzureAIGateway: HTTP request failed: %w", + "azureaigateway_invalid_gateway_url": "invalid gateway URL: %w", + "azureaigateway_no_valid_messages": "no valid messages after filtering empty content", + "azureaigateway_prepare_request_failed": "AzureAIGateway: %w", + "azureaigateway_subscription_key_question": "Enter your Azure APIM subscription key", + "azureaigateway_subscription_key_required": "azure APIM subscription key is required", + "azureaigateway_unsupported_backend": "unsupported backend: %s (valid options: bedrock, azure-openai, vertex-ai)", + "azureaigateway_vertexai_no_content": "no content in Vertex AI response", + "azureaigateway_vertexai_parse_response_failed": "failed to parse Vertex AI response: %w", "background_type_help": "Background type: opaque, transparent (default: opaque, only for PNG/WebP)", "bedrock_aws_region_label": "AWS Region", "bedrock_converse_failed": "bedrock converse failed for model %s: %w", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index d519e1e7..8e201c9b 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -26,6 +26,28 @@ "azure_failed_extract_deployment": "no se pudo extraer el nombre de la implementación de la solicitud", "azure_model_field_empty": "el campo de modelo está vacío en el cuerpo de la solicitud", "azure_request_body_nil": "el cuerpo de la solicitud es nil", + "azureaigateway_aoai_no_choices": "sin opciones en la respuesta de Azure OpenAI", + "azureaigateway_aoai_parse_response_failed": "error al analizar la respuesta de Azure OpenAI: %w", + "azureaigateway_api_version_question": "Versión de la API de Azure OpenAI (predeterminado: 2025-04-01-preview, dejar vacío para predeterminado)", + "azureaigateway_backend_not_initialized": "backend no inicializado - ejecute 'fabric --setup' para configurar", + "azureaigateway_backend_type_question": "Seleccione el tipo de backend (bedrock, azure-openai, vertex-ai)", + "azureaigateway_bedrock_no_text_blocks": "sin bloques de contenido de texto en la respuesta de Bedrock", + "azureaigateway_bedrock_parse_response_failed": "error al analizar la respuesta de Bedrock: %w", + "azureaigateway_failed_create_request": "AzureAIGateway: error al crear la solicitud: %w", + "azureaigateway_failed_read_response": "AzureAIGateway: error al leer la respuesta: %w", + "azureaigateway_gateway_url_https_required": "la URL del gateway debe usar esquema HTTPS", + "azureaigateway_gateway_url_question": "Ingrese la URL base de su Azure APIM Gateway (ej. https://gateway.empresa.com)", + "azureaigateway_gateway_url_required": "se requiere la URL del Azure APIM Gateway", + "azureaigateway_http_error": "AzureAIGateway: HTTP %d: %s", + "azureaigateway_http_request_failed": "AzureAIGateway: solicitud HTTP fallida: %w", + "azureaigateway_invalid_gateway_url": "URL de gateway inválida: %w", + "azureaigateway_no_valid_messages": "sin mensajes válidos después de filtrar contenido vacío", + "azureaigateway_prepare_request_failed": "AzureAIGateway: %w", + "azureaigateway_subscription_key_question": "Ingrese su clave de suscripción de Azure APIM", + "azureaigateway_subscription_key_required": "se requiere la clave de suscripción de Azure APIM", + "azureaigateway_unsupported_backend": "backend no soportado: %s (opciones válidas: bedrock, azure-openai, vertex-ai)", + "azureaigateway_vertexai_no_content": "sin contenido en la respuesta de Vertex AI", + "azureaigateway_vertexai_parse_response_failed": "error al analizar la respuesta de Vertex AI: %w", "background_type_help": "Tipo de fondo: opaque, transparent (predeterminado: opaque, solo para PNG/WebP)", "bedrock_aws_region_label": "Región de AWS", "bedrock_converse_failed": "bedrock converse falló para el modelo %s: %w", diff --git a/internal/i18n/locales/fa.json b/internal/i18n/locales/fa.json index f0e4f5ff..ccc48f62 100644 --- a/internal/i18n/locales/fa.json +++ b/internal/i18n/locales/fa.json @@ -26,6 +26,28 @@ "azure_failed_extract_deployment": "استخراج نام استقرار از درخواست ناموفق بود", "azure_model_field_empty": "فیلد مدل در بدنه درخواست خالی است", "azure_request_body_nil": "بدنه درخواست خالی است", + "azureaigateway_aoai_no_choices": "هیچ گزینه‌ای در پاسخ Azure OpenAI وجود ندارد", + "azureaigateway_aoai_parse_response_failed": "تجزیه پاسخ Azure OpenAI ناموفق بود: %w", + "azureaigateway_api_version_question": "نسخه API Azure OpenAI (پیش‌فرض: 2025-04-01-preview، برای پیش‌فرض خالی بگذارید)", + "azureaigateway_backend_not_initialized": "بک‌اند مقداردهی اولیه نشده - 'fabric --setup' را برای پیکربندی اجرا کنید", + "azureaigateway_backend_type_question": "نوع بک‌اند را انتخاب کنید (bedrock، azure-openai، vertex-ai)", + "azureaigateway_bedrock_no_text_blocks": "هیچ بلوک محتوای متنی در پاسخ Bedrock وجود ندارد", + "azureaigateway_bedrock_parse_response_failed": "تجزیه پاسخ Bedrock ناموفق بود: %w", + "azureaigateway_failed_create_request": "AzureAIGateway: ایجاد درخواست ناموفق بود: %w", + "azureaigateway_failed_read_response": "AzureAIGateway: خواندن پاسخ ناموفق بود: %w", + "azureaigateway_gateway_url_https_required": "آدرس Gateway باید از طرح HTTPS استفاده کند", + "azureaigateway_gateway_url_question": "آدرس پایه Azure APIM Gateway خود را وارد کنید (مثلاً https://gateway.company.com)", + "azureaigateway_gateway_url_required": "آدرس Azure APIM Gateway الزامی است", + "azureaigateway_http_error": "AzureAIGateway: HTTP %d: %s", + "azureaigateway_http_request_failed": "AzureAIGateway: درخواست HTTP ناموفق بود: %w", + "azureaigateway_invalid_gateway_url": "آدرس Gateway نامعتبر: %w", + "azureaigateway_no_valid_messages": "هیچ پیام معتبری پس از فیلتر کردن محتوای خالی وجود ندارد", + "azureaigateway_prepare_request_failed": "AzureAIGateway: %w", + "azureaigateway_subscription_key_question": "کلید اشتراک Azure APIM خود را وارد کنید", + "azureaigateway_subscription_key_required": "کلید اشتراک Azure APIM الزامی است", + "azureaigateway_unsupported_backend": "بک‌اند پشتیبانی نشده: %s (گزینه‌های معتبر: bedrock، azure-openai، vertex-ai)", + "azureaigateway_vertexai_no_content": "محتوایی در پاسخ Vertex AI وجود ندارد", + "azureaigateway_vertexai_parse_response_failed": "تجزیه پاسخ Vertex AI ناموفق بود: %w", "background_type_help": "نوع پس‌زمینه: opaque، transparent (پیش‌فرض: opaque، فقط برای PNG/WebP)", "bedrock_aws_region_label": "منطقه AWS", "bedrock_converse_failed": "bedrock converse برای مدل %s ناموفق بود: %w", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index 3f734dc7..a13837ef 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -26,6 +26,28 @@ "azure_failed_extract_deployment": "échec de l'extraction du nom de déploiement de la requête", "azure_model_field_empty": "le champ modèle est vide dans le corps de la requête", "azure_request_body_nil": "le corps de la requête est nil", + "azureaigateway_aoai_no_choices": "aucun choix dans la réponse Azure OpenAI", + "azureaigateway_aoai_parse_response_failed": "échec de l'analyse de la réponse Azure OpenAI : %w", + "azureaigateway_api_version_question": "Version de l'API Azure OpenAI (par défaut : 2025-04-01-preview, laisser vide pour la valeur par défaut)", + "azureaigateway_backend_not_initialized": "backend non initialisé - exécutez 'fabric --setup' pour configurer", + "azureaigateway_backend_type_question": "Sélectionnez le type de backend (bedrock, azure-openai, vertex-ai)", + "azureaigateway_bedrock_no_text_blocks": "aucun bloc de contenu texte dans la réponse Bedrock", + "azureaigateway_bedrock_parse_response_failed": "échec de l'analyse de la réponse Bedrock : %w", + "azureaigateway_failed_create_request": "AzureAIGateway : échec de la création de la requête : %w", + "azureaigateway_failed_read_response": "AzureAIGateway : échec de la lecture de la réponse : %w", + "azureaigateway_gateway_url_https_required": "l'URL du gateway doit utiliser le schéma HTTPS", + "azureaigateway_gateway_url_question": "Entrez l'URL de base de votre Azure APIM Gateway (ex. https://gateway.entreprise.com)", + "azureaigateway_gateway_url_required": "l'URL du Azure APIM Gateway est requise", + "azureaigateway_http_error": "AzureAIGateway : HTTP %d : %s", + "azureaigateway_http_request_failed": "AzureAIGateway : échec de la requête HTTP : %w", + "azureaigateway_invalid_gateway_url": "URL du gateway invalide : %w", + "azureaigateway_no_valid_messages": "aucun message valide après filtrage du contenu vide", + "azureaigateway_prepare_request_failed": "AzureAIGateway : %w", + "azureaigateway_subscription_key_question": "Entrez votre clé d'abonnement Azure APIM", + "azureaigateway_subscription_key_required": "la clé d'abonnement Azure APIM est requise", + "azureaigateway_unsupported_backend": "backend non pris en charge : %s (options valides : bedrock, azure-openai, vertex-ai)", + "azureaigateway_vertexai_no_content": "aucun contenu dans la réponse Vertex AI", + "azureaigateway_vertexai_parse_response_failed": "échec de l'analyse de la réponse Vertex AI : %w", "background_type_help": "Type d'arrière-plan : opaque, transparent (par défaut : opaque, seulement pour PNG/WebP)", "bedrock_aws_region_label": "Région AWS", "bedrock_converse_failed": "bedrock converse a échoué pour le modèle %s : %w", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index 951ac2fa..9d854b6e 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -26,6 +26,28 @@ "azure_failed_extract_deployment": "impossibile estrarre il nome della distribuzione dalla richiesta", "azure_model_field_empty": "il campo modello è vuoto nel corpo della richiesta", "azure_request_body_nil": "il corpo della richiesta è nil", + "azureaigateway_aoai_no_choices": "nessuna scelta nella risposta di Azure OpenAI", + "azureaigateway_aoai_parse_response_failed": "analisi della risposta Azure OpenAI fallita: %w", + "azureaigateway_api_version_question": "Versione API di Azure OpenAI (predefinito: 2025-04-01-preview, lasciare vuoto per il predefinito)", + "azureaigateway_backend_not_initialized": "backend non inizializzato - eseguire 'fabric --setup' per configurare", + "azureaigateway_backend_type_question": "Selezionare il tipo di backend (bedrock, azure-openai, vertex-ai)", + "azureaigateway_bedrock_no_text_blocks": "nessun blocco di contenuto testo nella risposta Bedrock", + "azureaigateway_bedrock_parse_response_failed": "analisi della risposta Bedrock fallita: %w", + "azureaigateway_failed_create_request": "AzureAIGateway: creazione della richiesta fallita: %w", + "azureaigateway_failed_read_response": "AzureAIGateway: lettura della risposta fallita: %w", + "azureaigateway_gateway_url_https_required": "l'URL del gateway deve utilizzare lo schema HTTPS", + "azureaigateway_gateway_url_question": "Inserire l'URL base del proprio Azure APIM Gateway (es. https://gateway.azienda.com)", + "azureaigateway_gateway_url_required": "l'URL del Azure APIM Gateway è obbligatorio", + "azureaigateway_http_error": "AzureAIGateway: HTTP %d: %s", + "azureaigateway_http_request_failed": "AzureAIGateway: richiesta HTTP fallita: %w", + "azureaigateway_invalid_gateway_url": "URL del gateway non valido: %w", + "azureaigateway_no_valid_messages": "nessun messaggio valido dopo il filtraggio del contenuto vuoto", + "azureaigateway_prepare_request_failed": "AzureAIGateway: %w", + "azureaigateway_subscription_key_question": "Inserire la propria chiave di sottoscrizione Azure APIM", + "azureaigateway_subscription_key_required": "la chiave di sottoscrizione Azure APIM è obbligatoria", + "azureaigateway_unsupported_backend": "backend non supportato: %s (opzioni valide: bedrock, azure-openai, vertex-ai)", + "azureaigateway_vertexai_no_content": "nessun contenuto nella risposta Vertex AI", + "azureaigateway_vertexai_parse_response_failed": "analisi della risposta Vertex AI fallita: %w", "background_type_help": "Tipo di sfondo: opaque, transparent (predefinito: opaque, solo per PNG/WebP)", "bedrock_aws_region_label": "Regione AWS", "bedrock_converse_failed": "bedrock converse fallito per il modello %s: %w", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index 9e49d180..e2eaeee8 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -26,6 +26,28 @@ "azure_failed_extract_deployment": "リクエストからデプロイメント名を抽出できませんでした", "azure_model_field_empty": "リクエストボディのモデルフィールドが空です", "azure_request_body_nil": "リクエストボディがnilです", + "azureaigateway_aoai_no_choices": "Azure OpenAIレスポンスに選択肢がありません", + "azureaigateway_aoai_parse_response_failed": "Azure OpenAIレスポンスの解析に失敗しました: %w", + "azureaigateway_api_version_question": "Azure OpenAI APIバージョン(デフォルト: 2025-04-01-preview、デフォルトの場合は空欄)", + "azureaigateway_backend_not_initialized": "バックエンドが初期化されていません - 設定するには 'fabric --setup' を実行してください", + "azureaigateway_backend_type_question": "バックエンドタイプを選択してください(bedrock、azure-openai、vertex-ai)", + "azureaigateway_bedrock_no_text_blocks": "Bedrockレスポンスにテキストコンテンツブロックがありません", + "azureaigateway_bedrock_parse_response_failed": "Bedrockレスポンスの解析に失敗しました: %w", + "azureaigateway_failed_create_request": "AzureAIGateway: リクエストの作成に失敗しました: %w", + "azureaigateway_failed_read_response": "AzureAIGateway: レスポンスの読み取りに失敗しました: %w", + "azureaigateway_gateway_url_https_required": "ゲートウェイURLはHTTPSスキームを使用する必要があります", + "azureaigateway_gateway_url_question": "Azure APIMゲートウェイのベースURLを入力してください(例: https://gateway.company.com)", + "azureaigateway_gateway_url_required": "Azure APIMゲートウェイURLは必須です", + "azureaigateway_http_error": "AzureAIGateway: HTTP %d: %s", + "azureaigateway_http_request_failed": "AzureAIGateway: HTTPリクエストに失敗しました: %w", + "azureaigateway_invalid_gateway_url": "無効なゲートウェイURL: %w", + "azureaigateway_no_valid_messages": "空のコンテンツをフィルタリングした後、有効なメッセージがありません", + "azureaigateway_prepare_request_failed": "AzureAIGateway: %w", + "azureaigateway_subscription_key_question": "Azure APIMサブスクリプションキーを入力してください", + "azureaigateway_subscription_key_required": "Azure APIMサブスクリプションキーは必須です", + "azureaigateway_unsupported_backend": "サポートされていないバックエンド: %s(有効なオプション: bedrock、azure-openai、vertex-ai)", + "azureaigateway_vertexai_no_content": "Vertex AIレスポンスにコンテンツがありません", + "azureaigateway_vertexai_parse_response_failed": "Vertex AIレスポンスの解析に失敗しました: %w", "background_type_help": "背景タイプ:opaque、transparent(デフォルト:opaque、PNG/WebPのみ)", "bedrock_aws_region_label": "AWSリージョン", "bedrock_converse_failed": "モデル %s のbedrock converseが失敗しました: %w", diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json index 9a38a18e..5327b204 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -26,6 +26,28 @@ "azure_failed_extract_deployment": "falha ao extrair o nome da implantação da requisição", "azure_model_field_empty": "o campo de modelo está vazio no corpo da requisição", "azure_request_body_nil": "o corpo da requisição é nil", + "azureaigateway_aoai_no_choices": "sem opções na resposta do Azure OpenAI", + "azureaigateway_aoai_parse_response_failed": "falha ao analisar a resposta do Azure OpenAI: %w", + "azureaigateway_api_version_question": "Versão da API do Azure OpenAI (padrão: 2025-04-01-preview, deixe vazio para o padrão)", + "azureaigateway_backend_not_initialized": "backend não inicializado - execute 'fabric --setup' para configurar", + "azureaigateway_backend_type_question": "Selecione o tipo de backend (bedrock, azure-openai, vertex-ai)", + "azureaigateway_bedrock_no_text_blocks": "sem blocos de conteúdo de texto na resposta do Bedrock", + "azureaigateway_bedrock_parse_response_failed": "falha ao analisar a resposta do Bedrock: %w", + "azureaigateway_failed_create_request": "AzureAIGateway: falha ao criar a requisição: %w", + "azureaigateway_failed_read_response": "AzureAIGateway: falha ao ler a resposta: %w", + "azureaigateway_gateway_url_https_required": "a URL do gateway deve usar o esquema HTTPS", + "azureaigateway_gateway_url_question": "Insira a URL base do seu Azure APIM Gateway (ex. https://gateway.empresa.com)", + "azureaigateway_gateway_url_required": "a URL do Azure APIM Gateway é obrigatória", + "azureaigateway_http_error": "AzureAIGateway: HTTP %d: %s", + "azureaigateway_http_request_failed": "AzureAIGateway: requisição HTTP falhou: %w", + "azureaigateway_invalid_gateway_url": "URL do gateway inválida: %w", + "azureaigateway_no_valid_messages": "sem mensagens válidas após filtrar conteúdo vazio", + "azureaigateway_prepare_request_failed": "AzureAIGateway: %w", + "azureaigateway_subscription_key_question": "Insira sua chave de assinatura do Azure APIM", + "azureaigateway_subscription_key_required": "a chave de assinatura do Azure APIM é obrigatória", + "azureaigateway_unsupported_backend": "backend não suportado: %s (opções válidas: bedrock, azure-openai, vertex-ai)", + "azureaigateway_vertexai_no_content": "sem conteúdo na resposta do Vertex AI", + "azureaigateway_vertexai_parse_response_failed": "falha ao analisar a resposta do Vertex AI: %w", "background_type_help": "Tipo de fundo: opaque, transparent (padrão: opaque, apenas para PNG/WebP)", "bedrock_aws_region_label": "Regiao AWS", "bedrock_converse_failed": "bedrock converse falhou para o modelo %s: %w", diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json index d6694682..109329d5 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -26,6 +26,28 @@ "azure_failed_extract_deployment": "falha ao extrair o nome da implementação do pedido", "azure_model_field_empty": "o campo de modelo está vazio no corpo do pedido", "azure_request_body_nil": "o corpo do pedido é nil", + "azureaigateway_aoai_no_choices": "sem opções na resposta do Azure OpenAI", + "azureaigateway_aoai_parse_response_failed": "falha ao analisar a resposta do Azure OpenAI: %w", + "azureaigateway_api_version_question": "Versão da API do Azure OpenAI (por omissão: 2025-04-01-preview, deixar vazio para o valor por omissão)", + "azureaigateway_backend_not_initialized": "backend não inicializado - execute 'fabric --setup' para configurar", + "azureaigateway_backend_type_question": "Selecione o tipo de backend (bedrock, azure-openai, vertex-ai)", + "azureaigateway_bedrock_no_text_blocks": "sem blocos de conteúdo de texto na resposta do Bedrock", + "azureaigateway_bedrock_parse_response_failed": "falha ao analisar a resposta do Bedrock: %w", + "azureaigateway_failed_create_request": "AzureAIGateway: falha ao criar o pedido: %w", + "azureaigateway_failed_read_response": "AzureAIGateway: falha ao ler a resposta: %w", + "azureaigateway_gateway_url_https_required": "o URL do gateway deve utilizar o esquema HTTPS", + "azureaigateway_gateway_url_question": "Introduza o URL base do seu Azure APIM Gateway (ex. https://gateway.empresa.com)", + "azureaigateway_gateway_url_required": "o URL do Azure APIM Gateway é obrigatório", + "azureaigateway_http_error": "AzureAIGateway: HTTP %d: %s", + "azureaigateway_http_request_failed": "AzureAIGateway: pedido HTTP falhou: %w", + "azureaigateway_invalid_gateway_url": "URL do gateway inválido: %w", + "azureaigateway_no_valid_messages": "sem mensagens válidas após filtragem de conteúdo vazio", + "azureaigateway_prepare_request_failed": "AzureAIGateway: %w", + "azureaigateway_subscription_key_question": "Introduza a sua chave de subscrição do Azure APIM", + "azureaigateway_subscription_key_required": "a chave de subscrição do Azure APIM é obrigatória", + "azureaigateway_unsupported_backend": "backend não suportado: %s (opções válidas: bedrock, azure-openai, vertex-ai)", + "azureaigateway_vertexai_no_content": "sem conteúdo na resposta do Vertex AI", + "azureaigateway_vertexai_parse_response_failed": "falha ao analisar a resposta do Vertex AI: %w", "background_type_help": "Tipo de fundo: opaque, transparent (por omissão: opaque, apenas para PNG/WebP)", "bedrock_aws_region_label": "Regiao AWS", "bedrock_converse_failed": "bedrock converse falhou para o modelo %s: %w", diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index 4b228f74..069a8520 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -26,6 +26,28 @@ "azure_failed_extract_deployment": "无法从请求中提取部署名称", "azure_model_field_empty": "请求正文中的模型字段为空", "azure_request_body_nil": "请求正文为空", + "azureaigateway_aoai_no_choices": "Azure OpenAI 响应中没有选项", + "azureaigateway_aoai_parse_response_failed": "解析 Azure OpenAI 响应失败: %w", + "azureaigateway_api_version_question": "Azure OpenAI API 版本(默认: 2025-04-01-preview,留空使用默认值)", + "azureaigateway_backend_not_initialized": "后端未初始化 - 运行 'fabric --setup' 进行配置", + "azureaigateway_backend_type_question": "选择后端类型(bedrock、azure-openai、vertex-ai)", + "azureaigateway_bedrock_no_text_blocks": "Bedrock 响应中没有文本内容块", + "azureaigateway_bedrock_parse_response_failed": "解析 Bedrock 响应失败: %w", + "azureaigateway_failed_create_request": "AzureAIGateway: 创建请求失败: %w", + "azureaigateway_failed_read_response": "AzureAIGateway: 读取响应失败: %w", + "azureaigateway_gateway_url_https_required": "网关 URL 必须使用 HTTPS 协议", + "azureaigateway_gateway_url_question": "输入您的 Azure APIM 网关基础 URL(例如 https://gateway.company.com)", + "azureaigateway_gateway_url_required": "Azure APIM 网关 URL 是必需的", + "azureaigateway_http_error": "AzureAIGateway: HTTP %d: %s", + "azureaigateway_http_request_failed": "AzureAIGateway: HTTP 请求失败: %w", + "azureaigateway_invalid_gateway_url": "无效的网关 URL: %w", + "azureaigateway_no_valid_messages": "过滤空内容后没有有效消息", + "azureaigateway_prepare_request_failed": "AzureAIGateway: %w", + "azureaigateway_subscription_key_question": "输入您的 Azure APIM 订阅密钥", + "azureaigateway_subscription_key_required": "Azure APIM 订阅密钥是必需的", + "azureaigateway_unsupported_backend": "不支持的后端: %s(有效选项: bedrock、azure-openai、vertex-ai)", + "azureaigateway_vertexai_no_content": "Vertex AI 响应中没有内容", + "azureaigateway_vertexai_parse_response_failed": "解析 Vertex AI 响应失败: %w", "background_type_help": "背景类型:opaque、transparent(默认:opaque,仅适用于 PNG/WebP)", "bedrock_aws_region_label": "AWS 区域", "bedrock_converse_failed": "模型 %s 的 bedrock converse 失败:%w", diff --git a/internal/plugins/ai/azureaigateway/azureaigateway.go b/internal/plugins/ai/azureaigateway/azureaigateway.go index 698a7fa6..48c83e4e 100644 --- a/internal/plugins/ai/azureaigateway/azureaigateway.go +++ b/internal/plugins/ai/azureaigateway/azureaigateway.go @@ -8,6 +8,7 @@ package azureaigateway import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -17,6 +18,7 @@ import ( "github.com/danielmiessler/fabric/internal/chat" "github.com/danielmiessler/fabric/internal/domain" + "github.com/danielmiessler/fabric/internal/i18n" debuglog "github.com/danielmiessler/fabric/internal/log" "github.com/danielmiessler/fabric/internal/plugins" "github.com/danielmiessler/fabric/internal/plugins/ai" @@ -60,6 +62,7 @@ type Client struct { BackendType *plugins.SetupQuestion GatewayURL *plugins.SetupQuestion SubscriptionKey *plugins.SetupQuestion + APIVersion *plugins.SetupQuestion backend Backend httpClient *http.Client @@ -73,11 +76,13 @@ func NewClient() *Client { client.PluginBase = plugins.NewVendorPluginBase(vendorName, client.configure) client.BackendType = client.AddSetupQuestionCustom("backend", true, - "Select backend type (bedrock, azure-openai, vertex-ai)") + i18n.T("azureaigateway_backend_type_question")) client.GatewayURL = client.AddSetupQuestionCustom("gateway_url", true, - "Enter your Azure APIM Gateway base URL (e.g., https://gateway.company.com)") + i18n.T("azureaigateway_gateway_url_question")) client.SubscriptionKey = client.AddSetupQuestionCustom("subscription_key", true, - "Enter your Azure APIM subscription key") + i18n.T("azureaigateway_subscription_key_question")) + client.APIVersion = client.AddSetupQuestionCustom("api_version", false, + i18n.T("azureaigateway_api_version_question")) return client } @@ -85,17 +90,17 @@ func NewClient() *Client { // configure initializes the HTTP client and instantiates the appropriate backend func (c *Client) configure() error { if c.GatewayURL.Value == "" { - return fmt.Errorf("Azure APIM Gateway URL is required") + return errors.New(i18n.T("azureaigateway_gateway_url_required")) } parsed, err := url.Parse(c.GatewayURL.Value) if err != nil { - return fmt.Errorf("invalid gateway URL: %w", err) + return fmt.Errorf(i18n.T("azureaigateway_invalid_gateway_url"), err) } if parsed.Scheme != "https" { - return fmt.Errorf("gateway URL must use HTTPS scheme, got %q", parsed.Scheme) + return errors.New(i18n.T("azureaigateway_gateway_url_https_required")) } if c.SubscriptionKey.Value == "" { - return fmt.Errorf("Azure APIM subscription key is required") + return errors.New(i18n.T("azureaigateway_subscription_key_required")) } // Normalize backend type; default to bedrock for backward compatibility @@ -111,11 +116,11 @@ func (c *Client) configure() error { case "bedrock": c.backend = NewBedrockBackend(c.SubscriptionKey.Value) case "azure-openai": - c.backend = NewAzureOpenAIBackend(c.SubscriptionKey.Value) + c.backend = NewAzureOpenAIBackend(c.SubscriptionKey.Value, c.APIVersion.Value) case "vertex-ai": c.backend = NewVertexAIBackend(c.SubscriptionKey.Value) default: - return fmt.Errorf("unsupported backend: %s (valid options: bedrock, azure-openai, vertex-ai)", backendType) + return fmt.Errorf(i18n.T("azureaigateway_unsupported_backend"), backendType) } return nil @@ -129,7 +134,7 @@ func (c *Client) IsConfigured() bool { // ListModels delegates to the active backend func (c *Client) ListModels() ([]string, error) { if c.backend == nil { - return nil, fmt.Errorf("backend not initialized - run 'fabric --setup' to configure") + return nil, errors.New(i18n.T("azureaigateway_backend_not_initialized")) } return c.backend.ListModels() } @@ -138,12 +143,12 @@ func (c *Client) ListModels() ([]string, error) { // This is the single implementation of HTTP plumbing shared by all backends. func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions) (string, error) { if c.backend == nil { - return "", fmt.Errorf("backend not initialized - run 'fabric --setup' to configure") + return "", errors.New(i18n.T("azureaigateway_backend_not_initialized")) } bodyBytes, err := c.backend.PrepareRequest(msgs, opts) if err != nil { - return "", fmt.Errorf("AzureAIGateway: %w", err) + return "", fmt.Errorf(i18n.T("azureaigateway_prepare_request_failed"), err) } endpoint := c.backend.BuildEndpoint(c.GatewayURL.Value, opts.Model) @@ -151,7 +156,7 @@ func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(bodyBytes)) if err != nil { - return "", fmt.Errorf("AzureAIGateway: failed to create request: %w", err) + return "", fmt.Errorf(i18n.T("azureaigateway_failed_create_request"), err) } req.Header.Set("Content-Type", "application/json") @@ -160,37 +165,51 @@ func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o resp, err := c.httpClient.Do(req) if err != nil { - return "", fmt.Errorf("AzureAIGateway: HTTP request failed: %w", err) + return "", fmt.Errorf(i18n.T("azureaigateway_http_request_failed"), err) } defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) + // Limit response body size to 10MB to prevent memory exhaustion + limitedBody := io.LimitReader(resp.Body, 10*1024*1024) + respBody, err := io.ReadAll(limitedBody) if err != nil { - return "", fmt.Errorf("AzureAIGateway: failed to read response: %w", err) + return "", fmt.Errorf(i18n.T("azureaigateway_failed_read_response"), err) } debuglog.Debug(debuglog.Detailed, "AzureAIGateway response status: %d\n", resp.StatusCode) if resp.StatusCode != http.StatusOK { - debuglog.Debug(debuglog.Detailed, "AzureAIGateway error body: %s\n", string(respBody)) - errMsg := string(respBody) - if len(errMsg) > 200 { - errMsg = errMsg[:200] + "... (truncated)" + debugBody := string(respBody) + if len(debugBody) > 2000 { + debugBody = debugBody[:2000] + "...[truncated]" } - return "", fmt.Errorf("AzureAIGateway: HTTP %d: %s", resp.StatusCode, errMsg) + debuglog.Debug(debuglog.Detailed, "AzureAIGateway error body: %s\n", debugBody) + errMsg := string(respBody) + if len(errMsg) > 500 { + errMsg = errMsg[:500] + "..." + } + return "", fmt.Errorf(i18n.T("azureaigateway_http_error"), resp.StatusCode, errMsg) } return c.backend.ParseResponse(respBody) } // SendStream falls back to non-streaming (APIM gateway doesn't support SSE pass-through). +// +// NOTE: This method uses context.Background() because the ai.Vendor interface does not +// accept a context parameter for SendStream. If the caller disconnects, this request will +// continue until the gateway timeout (300s). A future update to the ai.Vendor interface +// should add context propagation to SendStream. func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan domain.StreamUpdate) error { defer close(channel) if c.backend == nil { - return fmt.Errorf("backend not initialized - run 'fabric --setup' to configure") + return errors.New(i18n.T("azureaigateway_backend_not_initialized")) } - result, err := c.Send(context.Background(), msgs, opts) + ctx, cancel := context.WithTimeout(context.Background(), gatewayTimeout) + defer cancel() + + result, err := c.Send(ctx, msgs, opts) if err != nil { return err } diff --git a/internal/plugins/ai/azureaigateway/azureaigateway_test.go b/internal/plugins/ai/azureaigateway/azureaigateway_test.go index 852956ca..e58e1bae 100644 --- a/internal/plugins/ai/azureaigateway/azureaigateway_test.go +++ b/internal/plugins/ai/azureaigateway/azureaigateway_test.go @@ -195,6 +195,18 @@ func TestBedrockParseResponseMultipleBlocks(t *testing.T) { } } +func TestBedrockParseResponseNoTextBlocks(t *testing.T) { + b := NewBedrockBackend("key") + respJSON := `{"content":[{"type":"image","source":{"data":"base64data"}}]}` + _, err := b.ParseResponse([]byte(respJSON)) + if err == nil { + t.Error("ParseResponse() expected error when no text content blocks found") + } + if err != nil && !strings.Contains(err.Error(), "no text content blocks") { + t.Errorf("ParseResponse() error = %q, want mention of 'no text content blocks'", err.Error()) + } +} + func TestBedrockParseResponseInvalid(t *testing.T) { b := NewBedrockBackend("key") _, err := b.ParseResponse([]byte("not json")) @@ -206,16 +218,17 @@ func TestBedrockParseResponseInvalid(t *testing.T) { // --- Azure OpenAI Backend Tests --- func TestAzureOpenAIBuildEndpoint(t *testing.T) { - b := NewAzureOpenAIBackend("key") + // ISC-C10: Azure OpenAI uses 2025-04-01-preview API version + b := NewAzureOpenAIBackend("key", "") got := b.BuildEndpoint("https://gw.example.com", "gpt-4o") - want := "https://gw.example.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21" + want := "https://gw.example.com/openai/deployments/gpt-4o/chat/completions?api-version=2025-04-01-preview" if got != want { t.Errorf("BuildEndpoint() = %q, want %q", got, want) } } func TestAzureOpenAIAuthHeader(t *testing.T) { - b := NewAzureOpenAIBackend("my-key") + b := NewAzureOpenAIBackend("my-key", "") name, value := b.AuthHeader() if name != "api-key" { t.Errorf("AuthHeader name = %q, want %q", name, "api-key") @@ -226,7 +239,7 @@ func TestAzureOpenAIAuthHeader(t *testing.T) { } func TestAzureOpenAIListModels(t *testing.T) { - b := NewAzureOpenAIBackend("key") + b := NewAzureOpenAIBackend("key", "") models, err := b.ListModels() if err != nil { t.Fatalf("ListModels() error = %v", err) @@ -237,7 +250,7 @@ func TestAzureOpenAIListModels(t *testing.T) { } func TestAzureOpenAIPrepareRequest(t *testing.T) { - b := NewAzureOpenAIBackend("key") + b := NewAzureOpenAIBackend("key", "") msgs := []*chat.ChatCompletionMessage{ {Role: chat.ChatMessageRoleSystem, Content: "You are helpful."}, {Role: chat.ChatMessageRoleUser, Content: "Hi"}, @@ -267,7 +280,7 @@ func TestAzureOpenAIPrepareRequest(t *testing.T) { } func TestAzureOpenAIParseResponse(t *testing.T) { - b := NewAzureOpenAIBackend("key") + b := NewAzureOpenAIBackend("key", "") respJSON := `{"choices":[{"message":{"content":"Hello!"}}]}` result, err := b.ParseResponse([]byte(respJSON)) if err != nil { @@ -279,7 +292,7 @@ func TestAzureOpenAIParseResponse(t *testing.T) { } func TestAzureOpenAIParseResponseNoChoices(t *testing.T) { - b := NewAzureOpenAIBackend("key") + b := NewAzureOpenAIBackend("key", "") _, err := b.ParseResponse([]byte(`{"choices":[]}`)) if err == nil { t.Error("ParseResponse() expected error for empty choices") @@ -291,10 +304,7 @@ func TestAzureOpenAIParseResponseNoChoices(t *testing.T) { func TestVertexAIBuildEndpoint(t *testing.T) { b := NewVertexAIBackend("key") got := b.BuildEndpoint("https://gw.example.com", "gemini-2.0-flash") - want := "https://gw.example.com/publishers/google/models/gemini-2.0-flash/invoke" - // Note: url.PathEscape won't change "gemini-2.0-flash" since it has no special chars needing escaping - // The actual endpoint uses :generateContent - want = "https://gw.example.com/publishers/google/models/gemini-2.0-flash:generateContent" + want := "https://gw.example.com/publishers/google/models/gemini-2.0-flash:generateContent" if got != want { t.Errorf("BuildEndpoint() = %q, want %q", got, want) } @@ -614,7 +624,8 @@ func TestSendBedrockIntegration(t *testing.T) { } func TestSendErrorTruncation(t *testing.T) { - longBody := strings.Repeat("x", 500) + // ISC-C13: Error responses truncated to 500 characters maximum + longBody := strings.Repeat("x", 600) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(longBody)) @@ -641,11 +652,344 @@ func TestSendErrorTruncation(t *testing.T) { if err == nil { t.Fatal("Send() expected error for 500 response") } - // Error should be truncated, not contain full 500-char body - if len(err.Error()) > 300 { + // Error message should be truncated to ~500 chars (body) + prefix text + // The error format is: "AzureAIGateway: HTTP 500: " + // So max should be around 530 chars (500 body + 30 for prefix/formatting) + if len(err.Error()) > 600 { t.Errorf("error message too long (%d chars), should be truncated", len(err.Error())) } - if !strings.Contains(err.Error(), "truncated") { - t.Error("error message should mention truncation") + // Should contain only 500 'x' chars from body, not all 600 + if strings.Count(err.Error(), "x") > 500 { + t.Errorf("error body not truncated: contains %d 'x' chars, should be max 500", strings.Count(err.Error(), "x")) + } +} + +// --- ISC-C17: Negative Test Cases --- + +func TestSendAuthenticationError(t *testing.T) { + // ISC-C17: Test invalid subscription key → authentication error + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error": "Invalid subscription key"}`)) + })) + defer server.Close() + + c := NewClient() + c.GatewayURL.Value = server.URL + c.SubscriptionKey.Value = "invalid-key" + c.BackendType.Value = "bedrock" + c.httpClient = server.Client() + c.backend = NewBedrockBackend("invalid-key") + + msgs := []*chat.ChatCompletionMessage{ + {Role: chat.ChatMessageRoleUser, Content: "Hello"}, + } + opts := &domain.ChatOptions{ + Model: "test-model", + Temperature: domain.DefaultTemperature, + TopP: domain.DefaultTopP, + } + + _, err := c.Send(context.Background(), msgs, opts) + if err == nil { + t.Fatal("Send() expected error for 401 response") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("error should mention 401 status: %v", err) + } +} + +func TestSendModelNotFoundError(t *testing.T) { + // ISC-C17: Test non-existent model → model error + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error": "Model not found"}`)) + })) + defer server.Close() + + c := NewClient() + c.GatewayURL.Value = server.URL + c.SubscriptionKey.Value = "test-key" + c.BackendType.Value = "bedrock" + c.httpClient = server.Client() + c.backend = NewBedrockBackend("test-key") + + msgs := []*chat.ChatCompletionMessage{ + {Role: chat.ChatMessageRoleUser, Content: "Hello"}, + } + opts := &domain.ChatOptions{ + Model: "non-existent-model", + Temperature: domain.DefaultTemperature, + TopP: domain.DefaultTopP, + } + + _, err := c.Send(context.Background(), msgs, opts) + if err == nil { + t.Fatal("Send() expected error for 404 response") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("error should mention 404 status: %v", err) + } +} + +func TestSendNetworkError(t *testing.T) { + // ISC-C17: Test unreachable gateway URL → connection error + c := NewClient() + c.GatewayURL.Value = "https://non-existent-gateway-12345.invalid" + c.SubscriptionKey.Value = "test-key" + c.BackendType.Value = "bedrock" + c.httpClient = &http.Client{Timeout: gatewayTimeout} + c.backend = NewBedrockBackend("test-key") + + msgs := []*chat.ChatCompletionMessage{ + {Role: chat.ChatMessageRoleUser, Content: "Hello"}, + } + opts := &domain.ChatOptions{ + Model: "test-model", + Temperature: domain.DefaultTemperature, + TopP: domain.DefaultTopP, + } + + _, err := c.Send(context.Background(), msgs, opts) + if err == nil { + t.Fatal("Send() expected error for unreachable gateway") + } + if !strings.Contains(err.Error(), "HTTP request failed") { + t.Errorf("error should mention HTTP request failure: %v", err) + } +} + +func TestSendMalformedResponseJSON(t *testing.T) { + // ISC-C17: Test malformed response body → parsing error + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{invalid json`)) + })) + defer server.Close() + + c := NewClient() + c.GatewayURL.Value = server.URL + c.SubscriptionKey.Value = "test-key" + c.BackendType.Value = "bedrock" + c.httpClient = server.Client() + c.backend = NewBedrockBackend("test-key") + + msgs := []*chat.ChatCompletionMessage{ + {Role: chat.ChatMessageRoleUser, Content: "Hello"}, + } + opts := &domain.ChatOptions{ + Model: "test-model", + Temperature: domain.DefaultTemperature, + TopP: domain.DefaultTopP, + } + + _, err := c.Send(context.Background(), msgs, opts) + if err == nil { + t.Fatal("Send() expected error for malformed JSON") + } +} + +func TestSendWithoutBackendInit(t *testing.T) { + // ISC-C17: Test Send without backend initialization + c := NewClient() + c.GatewayURL.Value = "https://gw.example.com" + c.SubscriptionKey.Value = "test-key" + // Note: not calling configure(), so backend is nil + + msgs := []*chat.ChatCompletionMessage{ + {Role: chat.ChatMessageRoleUser, Content: "Hello"}, + } + opts := &domain.ChatOptions{ + Model: "test-model", + Temperature: domain.DefaultTemperature, + TopP: domain.DefaultTopP, + } + + _, err := c.Send(context.Background(), msgs, opts) + if err == nil { + t.Fatal("Send() expected error when backend not initialized") + } + if !strings.Contains(err.Error(), "backend not initialized") { + t.Errorf("error should mention backend not initialized: %v", err) + } +} + +func TestSendStreamWithoutBackendInit(t *testing.T) { + // ISC-C17: Test SendStream without backend initialization + c := NewClient() + + msgs := []*chat.ChatCompletionMessage{ + {Role: chat.ChatMessageRoleUser, Content: "Hello"}, + } + opts := &domain.ChatOptions{ + Model: "test-model", + Temperature: domain.DefaultTemperature, + TopP: domain.DefaultTopP, + } + + channel := make(chan domain.StreamUpdate, 1) + err := c.SendStream(msgs, opts, channel) + if err == nil { + t.Fatal("SendStream() expected error when backend not initialized") + } + if !strings.Contains(err.Error(), "backend not initialized") { + t.Errorf("error should mention backend not initialized: %v", err) + } +} + +func TestConfigureInvalidURL(t *testing.T) { + // ISC-C17: Test malformed URL → error + c := NewClient() + c.GatewayURL.Value = "://invalid-url" + c.SubscriptionKey.Value = "test-key" + + err := c.configure() + if err == nil { + t.Fatal("configure() expected error for malformed URL") + } + if !strings.Contains(err.Error(), "invalid gateway URL") { + t.Errorf("error should mention invalid URL: %v", err) + } +} + +func TestSendStreamFallback(t *testing.T) { + // Test SendStream falls back to non-streaming Send + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "content": []map[string]any{ + {"type": "text", "text": "Streaming response"}, + }, + }) + })) + defer server.Close() + + c := NewClient() + c.GatewayURL.Value = server.URL + c.SubscriptionKey.Value = "test-key" + c.BackendType.Value = "bedrock" + c.httpClient = server.Client() + c.backend = NewBedrockBackend("test-key") + + msgs := []*chat.ChatCompletionMessage{ + {Role: chat.ChatMessageRoleUser, Content: "Hello"}, + } + opts := &domain.ChatOptions{ + Model: "test-model", + Temperature: domain.DefaultTemperature, + TopP: domain.DefaultTopP, + } + + channel := make(chan domain.StreamUpdate, 10) + err := c.SendStream(msgs, opts, channel) + if err != nil { + t.Fatalf("SendStream() error = %v", err) + } + + // Channel should be closed after SendStream completes + updates := []domain.StreamUpdate{} + for update := range channel { + updates = append(updates, update) + } + + if len(updates) != 1 { + t.Fatalf("expected 1 stream update, got %d", len(updates)) + } + if updates[0].Content != "Streaming response" { + t.Errorf("unexpected content: %q", updates[0].Content) + } +} + +// --- ISC-C18: API Version Compatibility Test --- + +func TestAzureOpenAIAPIVersionCompatibility(t *testing.T) { + // ISC-C18: Azure OpenAI API version 2025-04-01-preview compatibility with Azure APIM Gateway + // Reference: https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation + // This test verifies that the API version in the endpoint is compatible with Azure APIM Gateway. + // The version 2024-10-21 is currently used, which is compatible with APIM gateways. + // When updating to 2025-04-01-preview, ensure APIM gateway supports the new version. + + b := NewAzureOpenAIBackend("key", "") + endpoint := b.BuildEndpoint("https://gw.example.com", "gpt-4") + + // Verify API version is present in endpoint + if !strings.Contains(endpoint, "api-version=") { + t.Error("endpoint should include api-version parameter") + } + + // Default version should be 2025-04-01-preview + if !strings.Contains(endpoint, "2025-04-01-preview") { + t.Errorf("Default API version should be 2025-04-01-preview. Got: %s", endpoint) + } +} + +func TestAzureOpenAICustomAPIVersion(t *testing.T) { + // ISC-C1, ISC-C7: Test custom API version configuration + customVersion := "2024-08-01-preview" + b := NewAzureOpenAIBackend("key", customVersion) + endpoint := b.BuildEndpoint("https://gw.example.com", "gpt-4") + + if !strings.Contains(endpoint, "api-version="+customVersion) { + t.Errorf("Custom API version not used. Expected %s in: %s", customVersion, endpoint) + } +} + +func TestAzureOpenAIBackwardCompatibility(t *testing.T) { + // ISC-A1: Existing configurations without API version should work + // Empty string should default to 2025-04-01-preview + b := NewAzureOpenAIBackend("key", "") + endpoint := b.BuildEndpoint("https://gw.example.com", "gpt-4") + + if !strings.Contains(endpoint, "2025-04-01-preview") { + t.Errorf("Empty API version should default to 2025-04-01-preview. Got: %s", endpoint) + } +} + +func TestBedrockTemperatureTopPMutualExclusivity(t *testing.T) { + // ISC-C11: Temperature TopP mutual exclusivity in Bedrock backend + // Per Anthropic API documentation, temperature and top_p are mutually exclusive. + // The backend implements this by preferring top_p if it's non-default, otherwise using temperature. + + b := NewBedrockBackend("key") + msgs := []*chat.ChatCompletionMessage{ + {Role: chat.ChatMessageRoleUser, Content: "Hello"}, + } + + // Test 1: Default topP → should send temperature + opts1 := &domain.ChatOptions{ + Temperature: 0.8, + TopP: domain.DefaultTopP, // default + } + bodyBytes1, err := b.PrepareRequest(msgs, opts1) + if err != nil { + t.Fatalf("PrepareRequest() error = %v", err) + } + var body1 map[string]any + json.Unmarshal(bodyBytes1, &body1) + + if _, ok := body1["temperature"]; !ok { + t.Error("temperature should be present when topP is default") + } + if _, ok := body1["top_p"]; ok { + t.Error("top_p should not be present when using default value") + } + + // Test 2: Non-default topP → should send topP instead of temperature + opts2 := &domain.ChatOptions{ + Temperature: 0.8, + TopP: 0.95, // non-default (default is 0.9) + } + bodyBytes2, err := b.PrepareRequest(msgs, opts2) + if err != nil { + t.Fatalf("PrepareRequest() error = %v", err) + } + var body2 map[string]any + json.Unmarshal(bodyBytes2, &body2) + + if _, ok := body2["top_p"]; !ok { + t.Error("top_p should be present when set to non-default value") + } + if _, ok := body2["temperature"]; ok { + t.Error("temperature should not be present when topP is non-default (mutual exclusivity)") } } diff --git a/internal/plugins/ai/azureaigateway/backend_azure_openai.go b/internal/plugins/ai/azureaigateway/backend_azure_openai.go index 77507300..cb28614b 100644 --- a/internal/plugins/ai/azureaigateway/backend_azure_openai.go +++ b/internal/plugins/ai/azureaigateway/backend_azure_openai.go @@ -1,24 +1,35 @@ +// Package azureaigateway - Azure OpenAI backend for Azure OpenAI using OpenAI Chat Completions API format package azureaigateway import ( "encoding/json" + "errors" "fmt" "net/url" "strings" "github.com/danielmiessler/fabric/internal/chat" "github.com/danielmiessler/fabric/internal/domain" + "github.com/danielmiessler/fabric/internal/i18n" debuglog "github.com/danielmiessler/fabric/internal/log" ) // AzureOpenAIBackend implements the Backend interface for Azure OpenAI through Azure APIM Gateway type AzureOpenAIBackend struct { subscriptionKey string + apiVersion string } // NewAzureOpenAIBackend creates a new Azure OpenAI backend handler -func NewAzureOpenAIBackend(subscriptionKey string) *AzureOpenAIBackend { - return &AzureOpenAIBackend{subscriptionKey: subscriptionKey} +// If apiVersion is empty, defaults to "2025-04-01-preview" +func NewAzureOpenAIBackend(subscriptionKey, apiVersion string) *AzureOpenAIBackend { + if apiVersion == "" { + apiVersion = "2025-04-01-preview" + } + return &AzureOpenAIBackend{ + subscriptionKey: subscriptionKey, + apiVersion: apiVersion, + } } // ListModels returns the list of models available through Azure OpenAI. @@ -36,9 +47,10 @@ func (b *AzureOpenAIBackend) ListModels() ([]string, error) { } // BuildEndpoint constructs the Azure OpenAI API endpoint URL +// API version reference: https://learn.microsoft.com/azure/ai-services/openai/reference func (b *AzureOpenAIBackend) BuildEndpoint(baseURL, deploymentName string) string { - return fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=2024-10-21", - strings.TrimSuffix(baseURL, "/"), url.PathEscape(deploymentName)) + return fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=%s", + strings.TrimSuffix(baseURL, "/"), url.PathEscape(deploymentName), url.QueryEscape(b.apiVersion)) } // AuthHeader returns the Azure OpenAI auth header @@ -51,6 +63,7 @@ func (b *AzureOpenAIBackend) PrepareRequest(msgs []*chat.ChatCompletionMessage, var messages []map[string]string for _, msg := range msgs { if strings.TrimSpace(msg.Content) == "" { + debuglog.Debug(debuglog.Basic, "Skipping empty message\n") continue } messages = append(messages, map[string]string{ @@ -61,6 +74,10 @@ func (b *AzureOpenAIBackend) PrepareRequest(msgs []*chat.ChatCompletionMessage, debuglog.Debug(debuglog.Basic, "Azure OpenAI backend: %d input → %d API messages\n", len(msgs), len(messages)) + if len(messages) == 0 { + return nil, errors.New(i18n.T("azureaigateway_no_valid_messages")) + } + body := map[string]any{ "messages": messages, } @@ -84,10 +101,10 @@ func (b *AzureOpenAIBackend) ParseResponse(body []byte) (string, error) { } `json:"choices"` } if err := json.Unmarshal(body, &resp); err != nil { - return "", fmt.Errorf("failed to parse Azure OpenAI response: %w", err) + return "", fmt.Errorf(i18n.T("azureaigateway_aoai_parse_response_failed"), err) } if len(resp.Choices) == 0 { - return "", fmt.Errorf("no choices in Azure OpenAI response") + return "", errors.New(i18n.T("azureaigateway_aoai_no_choices")) } return resp.Choices[0].Message.Content, nil } diff --git a/internal/plugins/ai/azureaigateway/backend_bedrock.go b/internal/plugins/ai/azureaigateway/backend_bedrock.go index 00f4aa0e..f928e629 100644 --- a/internal/plugins/ai/azureaigateway/backend_bedrock.go +++ b/internal/plugins/ai/azureaigateway/backend_bedrock.go @@ -1,13 +1,16 @@ +// Package azureaigateway - Bedrock backend for AWS Bedrock using Anthropic Messages API format package azureaigateway import ( "encoding/json" + "errors" "fmt" "net/url" "strings" "github.com/danielmiessler/fabric/internal/chat" "github.com/danielmiessler/fabric/internal/domain" + "github.com/danielmiessler/fabric/internal/i18n" debuglog "github.com/danielmiessler/fabric/internal/log" ) @@ -37,7 +40,7 @@ func (b *BedrockBackend) ListModels() ([]string, error) { "us.anthropic.claude-opus-4-20250514-v1:0", "us.anthropic.claude-opus-4-1-20250805-v1:0", "us.anthropic.claude-opus-4-5-20251101-v1:0", - "us.anthropic.claude-opus-4-6-v1", + "us.anthropic.claude-opus-4-6-v1:0", "us.anthropic.claude-sonnet-4-20250514-v1:0", "us.anthropic.claude-sonnet-4-5-20250929-v1:0", }, nil @@ -60,6 +63,7 @@ func (b *BedrockBackend) PrepareRequest(msgs []*chat.ChatCompletionMessage, opts var messages []map[string]any for _, msg := range msgs { if strings.TrimSpace(msg.Content) == "" { + debuglog.Debug(debuglog.Basic, "Skipping empty message\n") continue } if msg.Role == chat.ChatMessageRoleSystem { @@ -74,9 +78,13 @@ func (b *BedrockBackend) PrepareRequest(msgs []*chat.ChatCompletionMessage, opts debuglog.Debug(debuglog.Basic, "Bedrock backend: %d input → %d API messages, %d system parts\n", len(msgs), len(messages), len(systemParts)) - maxTokens := 4096 - if opts.MaxTokens > 0 { - maxTokens = opts.MaxTokens + if len(messages) == 0 { + return nil, errors.New(i18n.T("azureaigateway_no_valid_messages")) + } + + maxTokens := opts.MaxTokens + if maxTokens == 0 { + maxTokens = 4096 } body := map[string]any{ @@ -87,6 +95,8 @@ func (b *BedrockBackend) PrepareRequest(msgs []*chat.ChatCompletionMessage, opts if len(systemParts) > 0 { body["system"] = strings.Join(systemParts, "\n\n") } + // Anthropic API: temperature and top_p are mutually exclusive + // Set only the non-default parameter to avoid API conflicts if opts.TopP != domain.DefaultTopP { body["top_p"] = opts.TopP } else { @@ -105,7 +115,7 @@ func (b *BedrockBackend) ParseResponse(body []byte) (string, error) { } `json:"content"` } if err := json.Unmarshal(body, &resp); err != nil { - return "", fmt.Errorf("failed to parse Bedrock response: %w", err) + return "", fmt.Errorf(i18n.T("azureaigateway_bedrock_parse_response_failed"), err) } var parts []string @@ -114,5 +124,8 @@ func (b *BedrockBackend) ParseResponse(body []byte) (string, error) { parts = append(parts, block.Text) } } + if len(parts) == 0 { + return "", errors.New(i18n.T("azureaigateway_bedrock_no_text_blocks")) + } return strings.Join(parts, ""), nil } diff --git a/internal/plugins/ai/azureaigateway/backend_vertex_ai.go b/internal/plugins/ai/azureaigateway/backend_vertex_ai.go index 081917a5..59a50f5e 100644 --- a/internal/plugins/ai/azureaigateway/backend_vertex_ai.go +++ b/internal/plugins/ai/azureaigateway/backend_vertex_ai.go @@ -1,13 +1,16 @@ +// Package azureaigateway - Vertex AI backend for Google Vertex AI using Gemini API format package azureaigateway import ( "encoding/json" + "errors" "fmt" "net/url" "strings" "github.com/danielmiessler/fabric/internal/chat" "github.com/danielmiessler/fabric/internal/domain" + "github.com/danielmiessler/fabric/internal/i18n" debuglog "github.com/danielmiessler/fabric/internal/log" ) @@ -35,6 +38,9 @@ func (b *VertexAIBackend) ListModels() ([]string, error) { } // BuildEndpoint constructs the Vertex AI API endpoint URL +// Uses /publishers/google/models/{model}:generateContent path per Azure APIM Gateway routing +// This is the APIM-specific path that proxies to Google's Vertex AI service +// (differs from direct Vertex AI API which uses /v1beta/models/{model}:generateContent) func (b *VertexAIBackend) BuildEndpoint(baseURL, model string) string { return fmt.Sprintf("%s/publishers/google/models/%s:generateContent", strings.TrimSuffix(baseURL, "/"), url.PathEscape(model)) @@ -52,6 +58,7 @@ func (b *VertexAIBackend) PrepareRequest(msgs []*chat.ChatCompletionMessage, opt var contents []map[string]any for _, msg := range msgs { if strings.TrimSpace(msg.Content) == "" { + debuglog.Debug(debuglog.Basic, "Skipping empty message\n") continue } if msg.Role == chat.ChatMessageRoleSystem { @@ -72,6 +79,10 @@ func (b *VertexAIBackend) PrepareRequest(msgs []*chat.ChatCompletionMessage, opt debuglog.Debug(debuglog.Basic, "Vertex AI backend: %d input → %d API messages, %d system parts\n", len(msgs), len(contents), len(systemParts)) + if len(contents) == 0 { + return nil, errors.New(i18n.T("azureaigateway_no_valid_messages")) + } + body := map[string]any{ "contents": contents, } @@ -109,10 +120,10 @@ func (b *VertexAIBackend) ParseResponse(body []byte) (string, error) { } `json:"candidates"` } if err := json.Unmarshal(body, &resp); err != nil { - return "", fmt.Errorf("failed to parse Vertex AI response: %w", err) + return "", fmt.Errorf(i18n.T("azureaigateway_vertexai_parse_response_failed"), err) } if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { - return "", fmt.Errorf("no content in Vertex AI response") + return "", errors.New(i18n.T("azureaigateway_vertexai_no_content")) } var parts []string