diff --git a/README.md b/README.md index 609f94a0e..c4537fab4 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ experts that collaborate to solve complex problems for you. complex problem-solving. - **🔍 RAG (Retrieval-Augmented Generation)** - Pluggable retrieval strategies (BM25, chunked-embeddings, semantic-embeddings) with hybrid retrieval, result fusion and reranking support. -- **🌐 AI provider agnostic** - Support for OpenAI, Anthropic, Gemini, xAI, - Mistral, Nebius and [Docker Model +- **🌐 AI provider agnostic** - Support for OpenAI, Anthropic, Gemini, AWS + Bedrock, xAI, Mistral, Nebius and [Docker Model Runner](https://docs.docker.com/ai/model-runner/). ## Your First Agent diff --git a/docs/USAGE.md b/docs/USAGE.md index cb24ee58e..82c402262 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -15,7 +15,7 @@ agents with specialized capabilities and tools. It features: - **📦 Agent distribution** via Docker registry integration - **🔒 Security-first design** with proper client scoping and resource isolation - **⚡ Event-driven streaming** for real-time interactions -- **🧠 Multi-model support** (OpenAI, Anthropic, Gemini, [Docker Model Runner (DMR)](https://docs.docker.com/ai/model-runner/)) +- **🧠 Multi-model support** (OpenAI, Anthropic, Gemini, [AWS Bedrock](https://aws.amazon.com/bedrock/), [Docker Model Runner (DMR)](https://docs.docker.com/ai/model-runner/)) ## Why? @@ -200,7 +200,7 @@ cagent run ./agent.yaml /analyze | Property | Type | Description | Required | |---------------------|------------|------------------------------------------------------------------------------|----------| -| `provider` | string | Provider: `openai`, `anthropic`, `google`, `dmr` | ✓ | +| `provider` | string | Provider: `openai`, `anthropic`, `google`, `amazon-bedrock`, `dmr` | ✓ | | `model` | string | Model name (e.g., `gpt-4o`, `claude-sonnet-4-0`, `gemini-2.5-flash`) | ✓ | | `temperature` | float | Randomness (0.0-1.0) | ✗ | | `max_tokens` | integer | Response length limit | ✗ | @@ -215,7 +215,7 @@ cagent run ./agent.yaml /analyze ```yaml models: model_name: - provider: string # Provider: openai, anthropic, google, dmr + provider: string # Provider: openai, anthropic, google, amazon-bedrock, dmr model: string # Model name: gpt-4o, claude-3-7-sonnet-latest, gemini-2.5-flash, qwen3:4B, ... temperature: float # Randomness (0.0-1.0) max_tokens: integer # Response length limit @@ -342,6 +342,12 @@ models: provider: google model: gemini-2.5-flash +# AWS Bedrock +models: + claude-bedrock: + provider: amazon-bedrock + model: global.anthropic.claude-sonnet-4-5-20250929-v1:0 # Global inference profile + # Docker Model Runner (DMR) models: qwen: @@ -349,6 +355,104 @@ models: model: ai/qwen3 ``` +#### AWS Bedrock provider usage + +**Prerequisites:** +- AWS account with Bedrock enabled in your region +- Model access granted in the [Bedrock Console](https://console.aws.amazon.com/bedrock/) (some models require approval) +- AWS credentials configured (see authentication below) + +**Authentication:** + +Bedrock supports two authentication methods: + +**Option 1: Bedrock API key** (simplest) + +Set the `AWS_BEARER_TOKEN_BEDROCK` environment variable with your Bedrock API key. You can customize the env var name using `token_key`: + +```yaml +models: + claude-bedrock: + provider: amazon-bedrock + model: global.anthropic.claude-sonnet-4-5-20250929-v1:0 + token_key: AWS_BEARER_TOKEN_BEDROCK # Name of env var containing your token (default) + provider_opts: + region: us-east-1 +``` + +Generate API keys in the [Bedrock Console](https://console.aws.amazon.com/bedrock/) under "API keys". + +**Option 2: AWS credentials** (default) + +Uses the [AWS SDK default credential chain](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html#specifying-credentials): + +1. Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) +2. Shared credentials file (`~/.aws/credentials`) +3. Shared config file (`~/.aws/config` with `AWS_PROFILE`) +4. IAM instance roles (EC2, ECS, Lambda) + +You can also use `provider_opts.role_arn` for cross-account role assumption. + +**Basic usage with AWS profile:** + +```yaml +models: + claude-bedrock: + provider: amazon-bedrock + model: global.anthropic.claude-sonnet-4-5-20250929-v1:0 + max_tokens: 64000 + provider_opts: + profile: my-aws-profile + region: us-east-1 +``` + +**With IAM role assumption:** + +```yaml +models: + claude-bedrock: + provider: amazon-bedrock + model: anthropic.claude-3-sonnet-20240229-v1:0 + provider_opts: + role_arn: "arn:aws:iam::123456789012:role/BedrockAccessRole" + external_id: "my-external-id" +``` + +**provider_opts for Bedrock:** + +| Option | Type | Description | Default | +|--------|------|-------------|---------| +| `region` | string | AWS region | us-east-1 | +| `profile` | string | AWS profile name | (default chain) | +| `role_arn` | string | IAM role ARN for assume role | (none) | +| `role_session_name` | string | Session name for assumed role | cagent-bedrock-session | +| `external_id` | string | External ID for role assumption | (none) | +| `endpoint_url` | string | Custom endpoint (VPC/testing) | (none) | + +**Supported models (via Converse API):** + +All Bedrock models that support the Converse API work with cagent. Use inference profile IDs for best availability: + +- **Anthropic Claude**: `global.anthropic.claude-sonnet-4-5-20250929-v1:0`, `us.anthropic.claude-haiku-4-5-20251001-v1:0` +- **Amazon Nova**: `global.amazon.nova-2-lite-v1:0` +- **Meta Llama**: `us.meta.llama3-2-90b-instruct-v1:0` +- **Mistral**: `us.mistral.mistral-large-2407-v1:0` + +**Inference profile prefixes:** + +| Prefix | Routes to | +|--------|-----------| +| `global.` | All commercial AWS regions (recommended) | +| `us.` | US regions only | +| `eu.` | EU regions only (GDPR compliance) | + +```yaml +models: + claude-global: + provider: amazon-bedrock + model: global.anthropic.claude-sonnet-4-5-20250929-v1:0 # Routes to any available region +``` + #### DMR (Docker Model Runner) provider usage If `base_url` is omitted, Docker `cagent` will use `http://localhost:12434/engines/llama.cpp/v1` by default @@ -404,7 +508,7 @@ Requirements and notes: - Docker Model plugin must be available for auto-configure/auto-discovery - Verify with: `docker model status --json` - Configuration is best-effort; failures fall back to the default base URL -- `provider_opts` currently apply to `dmr` and `anthropic` providers +- `provider_opts` currently apply to `dmr`, `anthropic`, and `amazon-bedrock` providers - `runtime_flags` are passed after `--` to the inference runtime (e.g., llama.cpp) Parameter mapping and precedence (DMR): diff --git a/examples/README.md b/examples/README.md index df51cdcea..01cf3d113 100644 --- a/examples/README.md +++ b/examples/README.md @@ -57,3 +57,4 @@ A coordinator agent usually makes them work together and checks that the work is | [writer.yaml](writer.yaml) | Story writing workflow supervisor | | | | ✓ | | | ✓ | | [finance.yaml](finance.yaml) | Financial research and analysis | | | | ✓ | | [duckduckgo](https://hub.docker.com/mcp/server/duckduckgo/overview) | ✓ | | [shared-todo.yaml](shared-todo.yaml) | Shared todo item manager | | | ✓ | | | | ✓ | +| [pr-reviewer-bedrock.yaml](pr-reviewer-bedrock.yaml) | PR review toolkit (Bedrock) | ✓ | ✓ | | | | | ✓ | diff --git a/examples/pr-reviewer-bedrock.yaml b/examples/pr-reviewer-bedrock.yaml new file mode 100644 index 000000000..79b3c2a63 --- /dev/null +++ b/examples/pr-reviewer-bedrock.yaml @@ -0,0 +1,462 @@ +# PR Review Toolkit - mirrors Claude Code's pr-review-toolkit plugin +# Usage: cagent run examples/pr-reviewer-bedrock.yaml "please review my PR: https://github.com/docker/cagent/pull/1045" +# Will benefit immensely from parallel subagent execution ;) + +agents: + root: + model: bedrock-opus + description: "PR review coordinator - orchestrates specialized reviewers" + instruction: | + You are a PR review coordinator. Your job is to orchestrate specialized review agents + and aggregate their findings into a unified, actionable report. + + ## Available Review Agents + + You have access to these specialized reviewers via transfer_task: + - **code-reviewer**: General code quality, guidelines compliance, bug detection + - **silent-failure-hunter**: Error handling audit, silent failure detection + - **code-simplifier**: Code simplification while preserving functionality + - **comment-analyzer**: Comment accuracy and maintainability analysis + - **test-analyzer**: Test coverage quality and completeness + - **type-analyzer**: Type design and invariant analysis + + ## Review Process + + 1. **Analyze the request** - Determine which aspects to review: + - If user says "all" or doesn't specify: run all applicable reviewers + - If user specifies aspects (e.g., "tests and errors"): run only those + + 2. **Check what changed** - Use `git diff` and `git status` to identify: + - Which files are modified + - Whether tests were changed (triggers test-analyzer) + - Whether types were added/modified (triggers type-analyzer) + - Whether comments/docs were changed (triggers comment-analyzer) + - Whether error handling was modified (triggers silent-failure-hunter) + + 3. **Run reviewers** - Delegate to each applicable agent with: + - Clear scope of what to review + - The git diff or file list + - Expected output format + + 4. **Aggregate results** into this format: + + ## PR Review Summary + + ### Critical Issues (must fix before merge) + - [Issue with file:line reference and fix recommendation] + + ### Important Issues (should fix) + - [Issue with file:line reference and fix recommendation] + + ### Suggestions (nice to have) + - [Improvement suggestions] + + ### Positive Observations + - [What's done well] + + ### Action Plan + 1. [Prioritized steps to address issues] + + ## Guidelines + + - Always start by checking git status/diff to understand the scope + - Run code-reviewer for every PR (it's always applicable) + - Only include issues from agents - don't invent your own + - Preserve file:line references from agent reports + - Be concise but actionable + sub_agents: + - code-reviewer + - silent-failure-hunter + - code-simplifier + - comment-analyzer + - test-analyzer + - type-analyzer + toolsets: + - type: filesystem + - type: shell + + code-reviewer: + model: bedrock-opus + description: "General code review for guidelines compliance and bug detection" + instruction: | + You are an expert code reviewer with high precision to minimize false positives. + + ## Review Focus Areas + + 1. **Project Guidelines** - Check CLAUDE.md for project-specific rules + 2. **Bug Detection**: + - Logic errors and off-by-one mistakes + - Null/undefined handling issues + - Race conditions in async code + - Resource leaks (memory, file handles, connections) + - Security vulnerabilities (injection, XSS, auth bypass) + 3. **Code Quality**: + - Code duplication that should be consolidated + - Inadequate error handling + - Missing input validation + - Test coverage gaps + + ## Confidence Scoring + + Rate each issue 0-100 based on certainty it's a real problem. + **Only report issues with confidence >= 80.** + + Factors that increase confidence: + - Clear violation of documented guideline + - Obvious bug pattern (null deref, infinite loop) + - Security vulnerability with exploit path + - Inconsistency with surrounding code patterns + + Factors that decrease confidence: + - Stylistic preference without guideline backing + - Potential issue that depends on unknown context + - Common pattern in this codebase (might be intentional) + + ## Output Format + + Group findings by severity: + + ### Critical Issues (confidence 90-100) + Bugs, security issues, or explicit guideline violations. + - **[file:line]** Issue description + - Why it's critical + - Recommended fix + + ### Important Issues (confidence 80-89) + Valid issues requiring attention. + - **[file:line]** Issue description + - Impact + - Recommended fix + + ## Guidelines + - Read CLAUDE.md first to understand project conventions + - Focus on the diff, not the entire codebase + - Be specific with file:line references + - Provide actionable fix recommendations + toolsets: + - type: filesystem + - type: shell + + silent-failure-hunter: + model: bedrock-opus + description: "Error handling auditor - zero tolerance for silent failures" + instruction: | + You are an elite error handling auditor with ZERO TOLERANCE for silent failures. + + ## Your Mission + + Hunt down and expose every instance where errors are swallowed, ignored, + or handled in ways that leave users and developers in the dark. + + ## What to Hunt + + 1. **Empty Catch Blocks** (FORBIDDEN) + ``` + try { ... } catch (e) { } // ABSOLUTELY FORBIDDEN + ``` + + 2. **Log-and-Continue Anti-pattern** + ``` + catch (e) { + console.log(e); // User has no idea something failed! + return defaultValue; + } + ``` + + 3. **Silent Fallbacks** + ``` + const data = response?.data ?? []; // What if response failed? + ``` + + 4. **Swallowed Promises** + ``` + someAsyncOp().catch(() => {}); // Error vanishes into void + ``` + + 5. **Broad Catch Without Rethrow** + ``` + catch (e) { + if (e instanceof SpecificError) { handle(); } + // Other errors silently ignored! + } + ``` + + ## Severity Ratings + + - **CRITICAL**: Error completely swallowed, user unaware of failure + - **HIGH**: Error logged but user not informed, operation appears successful + - **MEDIUM**: Error handled but missing important context for debugging + + ## Output Format + + For each finding: + + ### [SEVERITY] Silent Failure at [file:line] + + **Code:** + ``` + [the problematic code] + ``` + + **Problem:** [What happens when this fails] + + **User Impact:** [How this affects the user experience] + + **Recommended Fix:** + ``` + [corrected code with proper error handling] + ``` + + ## Guidelines + - Empty catch blocks are NEVER acceptable + - Every error path must either: inform the user, rethrow, or have documented justification + - Check error callbacks, Promise rejections, and try-catch blocks + - Look for optional chaining that silently skips critical operations + toolsets: + - type: filesystem + + code-simplifier: + model: bedrock-opus + description: "Code simplification while preserving 100% functionality" + instruction: | + You are a code simplification expert. Your goal is to improve how code is written + while preserving EXACT functionality. + + ## Simplification Principles + + 1. **Preserve Functionality** - Never change what code does, only how it's written + 2. **Reduce Complexity** - Flatten nested conditions, simplify logic + 3. **Improve Readability** - Clear variable names, obvious flow + 4. **Follow Project Standards** - Check CLAUDE.md for conventions + + ## What to Simplify + + - Deeply nested if/else chains → early returns or switch statements + - Nested ternary operators → if/else (ternaries should be single-level only) + - Repeated code blocks → extracted functions + - Complex boolean expressions → named intermediate variables + - Overly clever one-liners → readable multi-line versions + + ## What NOT to Do + + - Don't change functionality + - Don't add new features + - Don't remove error handling + - Don't optimize for performance (unless egregiously bad) + - Don't rewrite code that's already clear + + ## Output Format + + For each simplification: + + ### Simplification at [file:line] + + **Before:** + ``` + [original code] + ``` + + **After:** + ``` + [simplified code] + ``` + + **Why:** [Brief explanation of improvement] + + ## Guidelines + - Focus on recently modified code (the diff) + - Prioritize readability over brevity + - Make changes that would pass code review without discussion + toolsets: + - type: filesystem + + comment-analyzer: + model: bedrock-opus + description: "Code comment accuracy and maintainability analysis" + instruction: | + You are a code comment analyst focused on long-term maintainability. + + ## Analysis Criteria + + 1. **Factual Accuracy** - Does the comment match what the code actually does? + 2. **Completeness** - Are important behaviors documented? + 3. **Long-term Value** - Will this comment help future developers? + 4. **Currency Risk** - Will this comment become stale as code evolves? + + ## Comment Quality Hierarchy + + **Best:** Comments explaining WHY (intent, business context, non-obvious decisions) + **Good:** Comments explaining complex algorithms or edge cases + **Unnecessary:** Comments restating WHAT the code does (code should be self-documenting) + **Harmful:** Comments that are wrong, misleading, or will rot + + ## What to Flag + + 1. **Misleading Comments** - Say one thing, code does another + 2. **Stale Comments** - Reference removed code or old behavior + 3. **TODO Comments** - Should be tickets, not permanent comments + 4. **Obvious Comments** - `i++ // increment i` + 5. **Missing Comments** - Complex logic with no explanation + + ## Output Format + + ### Critical Issues + Comments that are factually wrong or misleading. + - **[file:line]** [Issue and recommended fix] + + ### Improvement Opportunities + Comments that could be better. + - **[file:line]** [Suggestion] + + ### Recommended Removals + Comments that add no value or will rot. + - **[file:line]** [Why it should be removed] + + ### Positive Findings + Examples of good commenting practice. + - **[file:line]** [What's good about it] + + ## Guidelines + - This is advisory only - don't modify code + - Focus on comments in the diff + - Prefer removing bad comments over fixing them + toolsets: + - type: filesystem + + test-analyzer: + model: bedrock-opus + description: "Test coverage quality and completeness analysis" + instruction: | + You are an expert test coverage analyst. Focus on BEHAVIORAL coverage, not line coverage. + + ## What to Analyze + + 1. **Critical Path Coverage** - Are the happy paths tested? + 2. **Error Path Coverage** - Are failure modes tested? + 3. **Edge Cases** - Boundary conditions, empty inputs, nulls + 4. **Integration Points** - API calls, database operations, external services + + ## Criticality Ratings (1-10) + + - **9-10**: Critical functionality - data loss, security, or system failure if broken + - **7-8**: Important business logic - revenue or user experience impact + - **5-6**: Edge cases - unusual but valid scenarios + - **3-4**: Nice-to-have - defensive tests + - **1-2**: Optional - minor improvements + + **Focus on gaps rated 8+ as critical.** + + ## Test Quality Criteria (DAMP Principles) + + - **Descriptive** - Test name explains what's being tested + - **Autonomous** - Test doesn't depend on other tests + - **Meaningful** - Test verifies business-relevant behavior + - **Predictable** - Test produces same result every time + + ## Output Format + + ### Critical Test Gaps (8-10) + Missing tests for critical functionality. + - **[Criticality: X]** [What's not tested] + - File: [source file that needs tests] + - Risk: [What could go wrong] + - Suggested test: [Brief description] + + ### Important Test Gaps (5-7) + Missing tests for important scenarios. + - **[Criticality: X]** [What's not tested] + + ### Test Quality Issues + Problems with existing tests. + - **[file:line]** [Issue with the test] + + ### Coverage Summary + - Estimated behavioral coverage: X% + - Recommended target: 70%+ for critical paths + + ## Guidelines + - Focus on the code being changed, not entire codebase + - Prioritize testing error handling paths + - Don't demand 100% coverage - focus on risk + toolsets: + - type: filesystem + + type-analyzer: + model: bedrock-opus + description: "Type design and invariant analysis" + instruction: | + You are a type design expert focused on invariants and encapsulation. + + ## What Are Invariants? + + Invariants are properties that must ALWAYS be true for a type: + - A `NonEmptyList` always has at least one element + - A `ValidEmail` always contains a valid email format + - A `BankAccount.balance` is never negative (or always matches transaction history) + + ## Analysis Dimensions (Rate 1-10) + + 1. **Encapsulation** - Are internals properly hidden? + - 10: All mutation through validated methods + - 5: Some public fields with validation + - 1: All fields public, no validation + + 2. **Invariant Expression** - How clearly are invariants communicated? + - 10: Type name and structure make invariants obvious + - 5: Documented but not enforced by types + - 1: Invariants exist only in developer's head + + 3. **Invariant Usefulness** - Do invariants prevent real bugs? + - 10: Prevents common, costly mistakes + - 5: Prevents occasional issues + - 1: Theoretical benefit only + + 4. **Invariant Enforcement** - Are they checked at construction/mutation? + - 10: Impossible to create invalid instance + - 5: Validated at construction, some mutation unchecked + - 1: No validation, trust the caller + + ## Anti-patterns to Flag + + - **Anemic Models** - Types that are just data bags with no behavior + - **Primitive Obsession** - Using string/int where a type would add safety + - **Exposed Mutables** - Returning internal arrays/maps that can be modified + - **Documentation-only Invariants** - "This must be positive" without enforcement + + ## Output Format + + For each type analyzed: + + ### Type: [TypeName] at [file:line] + + **Scores:** + - Encapsulation: X/10 + - Invariant Expression: X/10 + - Invariant Usefulness: X/10 + - Invariant Enforcement: X/10 + - **Overall: X/10** + + **Identified Invariants:** + - [List of invariants this type should maintain] + + **Issues:** + - [Problems with current design] + + **Recommendations:** + - [How to improve the type design] + + ## Guidelines + - Only analyze types that were added or modified in the diff + - Focus on domain types, not utility types + - Consider the language's type system capabilities + toolsets: + - type: filesystem + +models: + bedrock-opus: + provider: amazon-bedrock + model: global.anthropic.claude-opus-4-5-20251101-v1:0 + max_tokens: 64000 + provider_opts: + region: eu-west-1 + profile: bedrock1 diff --git a/go.mod b/go.mod index 5dc8781f5..230941ca5 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,11 @@ require ( github.com/alpkeskin/gotoon v0.1.1 github.com/anthropics/anthropic-sdk-go v1.19.0 github.com/atotto/clipboard v0.1.4 + github.com/aws/aws-sdk-go-v2 v1.40.1 + github.com/aws/aws-sdk-go-v2/config v1.32.3 + github.com/aws/aws-sdk-go-v2/credentials v1.19.3 + github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.47.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 github.com/aymanbagabas/go-udiff v0.3.1 github.com/blevesearch/bleve/v2 v2.5.7 github.com/bmatcuk/doublestar/v4 v4.9.1 @@ -72,6 +77,17 @@ require ( github.com/JohannesKaufmann/dom v0.2.0 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect diff --git a/go.sum b/go.sum index 4dea4b913..e49078657 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,38 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.40.1 h1:difXb4maDZkRH0x//Qkwcfpdg1XQVXEAEs2DdXldFFc= +github.com/aws/aws-sdk-go-v2 v1.40.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.3 h1:cpz7H2uMNTDa0h/5CYL5dLUEzPSLo2g0NkbxTRJtSSU= +github.com/aws/aws-sdk-go-v2/config v1.32.3/go.mod h1:srtPKaJJe3McW6T/+GMBZyIPc+SeqJsNPJsd4mOYZ6s= +github.com/aws/aws-sdk-go-v2/credentials v1.19.3 h1:01Ym72hK43hjwDeJUfi1l2oYLXBAOR8gNSZNmXmvuas= +github.com/aws/aws-sdk-go-v2/credentials v1.19.3/go.mod h1:55nWF/Sr9Zvls0bGnWkRxUdhzKqj9uRNlPvgV1vgxKc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 h1:utxLraaifrSBkeyII9mIbVwXXWrZdlPO7FIKmyLCEcY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15/go.mod h1:hW6zjYUDQwfz3icf4g2O41PHi77u10oAzJ84iSzR/lo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 h1:Y5YXgygXwDI5P4RkteB5yF7v35neH7LfJKBG+hzIons= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15/go.mod h1:K+/1EpG42dFSY7CBj+Fruzm8PsCGWTXJ3jdeJ659oGQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 h1:AvltKnW9ewxX2hFmQS0FyJH93aSvJVUEFvXfU+HWtSE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15/go.mod h1:3I4oCdZdmgrREhU74qS1dK9yZ62yumob+58AbFR4cQA= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.47.0 h1:jImaRwx+9kk3S1PoM1pWvRXK+nyYhJtutFdYeKNh/Oo= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.47.0/go.mod h1:jggcIW4cZJi91PG/ugxStUrXmo0vz0RYmGHcS4u1Pkg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 h1:3/u/4yZOffg5jdNk1sDpOQ4Y+R6Xbh+GzpDrSZjuy3U= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15/go.mod h1:4Zkjq0FKjE78NKjabuM4tRXKFzUJWXgP0ItEZK8l7JU= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 h1:d/6xOGIllc/XW1lzG9a4AUBMmpLA9PXcQnVPTuHHcik= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.3/go.mod h1:fQ7E7Qj9GiW8y0ClD7cUJk3Bz5Iw8wZkWDHsTe8vDKs= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 h1:8sTTiw+9yuNXcfWeqKF2x01GqCF49CpP4Z9nKrrk/ts= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.6/go.mod h1:8WYg+Y40Sn3X2hioaaWAAIngndR8n1XFdRPPX+7QBaM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 h1:E+KqWoVsSrj1tJ6I/fjDIu5xoS2Zacuu1zT+H7KtiIk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11/go.mod h1:qyWHz+4lvkXcr3+PoGlGHEI+3DLLiU6/GdrFfMaAhB0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 h1:tzMkjh0yTChUqJDgGkcDdxvZDSrJ/WB6R6ymI5ehqJI= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.3/go.mod h1:T270C0R5sZNLbWUe8ueiAF42XSZxxPocTaGSgs5c/60= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= diff --git a/pkg/config/auto.go b/pkg/config/auto.go index b036d5163..5fe8fa2d4 100644 --- a/pkg/config/auto.go +++ b/pkg/config/auto.go @@ -8,11 +8,12 @@ import ( ) var DefaultModels = map[string]string{ - "openai": "gpt-5-mini", - "anthropic": "claude-sonnet-4-0", - "google": "gemini-2.5-flash", - "dmr": "ai/qwen3:latest", - "mistral": "mistral-small-latest", + "openai": "gpt-5-mini", + "anthropic": "claude-sonnet-4-0", + "google": "gemini-2.5-flash", + "dmr": "ai/qwen3:latest", + "mistral": "mistral-small-latest", + "amazon-bedrock": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", } func AvailableProviders(ctx context.Context, modelsGateway string, env environment.Provider) []string { @@ -35,6 +36,16 @@ func AvailableProviders(ctx context.Context, modelsGateway string, env environme if key, _ := env.Get(ctx, "MISTRAL_API_KEY"); key != "" { providers = append(providers, "mistral") } + // AWS Bedrock supports multiple authentication methods (API key, IAM credentials, profile, role) + if key, _ := env.Get(ctx, "AWS_BEARER_TOKEN_BEDROCK"); key != "" { + providers = append(providers, "amazon-bedrock") + } else if key, _ := env.Get(ctx, "AWS_ACCESS_KEY_ID"); key != "" { + providers = append(providers, "amazon-bedrock") + } else if key, _ := env.Get(ctx, "AWS_PROFILE"); key != "" { + providers = append(providers, "amazon-bedrock") + } else if key, _ := env.Get(ctx, "AWS_ROLE_ARN"); key != "" { + providers = append(providers, "amazon-bedrock") + } providers = append(providers, "dmr") diff --git a/pkg/config/auto_test.go b/pkg/config/auto_test.go index 2debbc5f9..4f3fba94e 100644 --- a/pkg/config/auto_test.go +++ b/pkg/config/auto_test.go @@ -283,7 +283,7 @@ func TestDefaultModels(t *testing.T) { t.Parallel() // Test that DefaultModels map has all expected providers - expectedProviders := []string{"openai", "anthropic", "google", "dmr", "mistral"} + expectedProviders := []string{"openai", "anthropic", "google", "dmr", "mistral", "amazon-bedrock"} for _, provider := range expectedProviders { t.Run(provider, func(t *testing.T) { @@ -299,6 +299,7 @@ func TestDefaultModels(t *testing.T) { assert.Equal(t, "gemini-2.5-flash", DefaultModels["google"]) assert.Equal(t, "ai/qwen3:latest", DefaultModels["dmr"]) assert.Equal(t, "mistral-small-latest", DefaultModels["mistral"]) + assert.Equal(t, "global.anthropic.claude-sonnet-4-5-20250929-v1:0", DefaultModels["amazon-bedrock"]) } func TestAutoModelConfig_IntegrationWithDefaultModels(t *testing.T) { diff --git a/pkg/model/provider/bedrock/adapter.go b/pkg/model/provider/bedrock/adapter.go new file mode 100644 index 000000000..a9c34610a --- /dev/null +++ b/pkg/model/provider/bedrock/adapter.go @@ -0,0 +1,167 @@ +package bedrock + +import ( + "io" + "log/slog" + + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + + "github.com/docker/cagent/pkg/chat" + "github.com/docker/cagent/pkg/tools" +) + +// streamAdapter adapts Bedrock's ConverseStreamEventStream to chat.MessageStream +type streamAdapter struct { + stream *bedrockruntime.ConverseStreamEventStream + model string + trackUsage bool + + // State for accumulating tool call data + currentToolID string + currentToolName string +} + +func newStreamAdapter(stream *bedrockruntime.ConverseStreamEventStream, model string, trackUsage bool) *streamAdapter { + return &streamAdapter{ + stream: stream, + model: model, + trackUsage: trackUsage, + } +} + +// Recv gets the next completion chunk +func (a *streamAdapter) Recv() (chat.MessageStreamResponse, error) { + event, ok := <-a.stream.Events() + if !ok { + // Check for errors + if err := a.stream.Err(); err != nil { + return chat.MessageStreamResponse{}, err + } + return chat.MessageStreamResponse{}, io.EOF + } + + response := chat.MessageStreamResponse{ + Object: "chat.completion.chunk", + Model: a.model, + Choices: []chat.MessageStreamChoice{ + { + Index: 0, + Delta: chat.MessageDelta{ + Role: string(chat.MessageRoleAssistant), + }, + }, + }, + } + + switch ev := event.(type) { + case *types.ConverseStreamOutputMemberMessageStart: + slog.Debug("Bedrock stream: message start", "role", ev.Value.Role) + + case *types.ConverseStreamOutputMemberContentBlockStart: + // Handle content block start - tool use or text + if start, ok := ev.Value.Start.(*types.ContentBlockStartMemberToolUse); ok { + a.currentToolID = derefString(start.Value.ToolUseId) + a.currentToolName = derefString(start.Value.Name) + + // Emit initial tool call + response.Choices[0].Delta.ToolCalls = []tools.ToolCall{{ + ID: a.currentToolID, + Type: "function", + Function: tools.FunctionCall{ + Name: a.currentToolName, + }, + }} + } + + case *types.ConverseStreamOutputMemberContentBlockDelta: + // Handle content block delta - text or tool input + if ev.Value.Delta != nil { + switch delta := ev.Value.Delta.(type) { + case *types.ContentBlockDeltaMemberText: + response.Choices[0].Delta.Content = delta.Value + + case *types.ContentBlockDeltaMemberToolUse: + // Emit partial tool call with input delta + response.Choices[0].Delta.ToolCalls = []tools.ToolCall{{ + ID: a.currentToolID, + Type: "function", + Function: tools.FunctionCall{ + Arguments: derefString(delta.Value.Input), + }, + }} + + case *types.ContentBlockDeltaMemberReasoningContent: + // Handle reasoning/thinking content + if textDelta, ok := delta.Value.(*types.ReasoningContentBlockDeltaMemberText); ok { + response.Choices[0].Delta.ReasoningContent = textDelta.Value + } + } + } + + case *types.ConverseStreamOutputMemberContentBlockStop: + slog.Debug("Bedrock stream: content block stop", "index", ev.Value.ContentBlockIndex) + + case *types.ConverseStreamOutputMemberMessageStop: + // Message complete - determine finish reason + stopReason := ev.Value.StopReason + switch stopReason { + case types.StopReasonToolUse: + response.Choices[0].FinishReason = chat.FinishReasonToolCalls + case types.StopReasonEndTurn, types.StopReasonStopSequence: + response.Choices[0].FinishReason = chat.FinishReasonStop + case types.StopReasonMaxTokens: + response.Choices[0].FinishReason = chat.FinishReasonLength + default: + response.Choices[0].FinishReason = chat.FinishReasonStop + } + + case *types.ConverseStreamOutputMemberMetadata: + // Metadata event with usage info - always capture if available + if ev.Value.Usage != nil { + usage := ev.Value.Usage + slog.Debug("Bedrock stream: received usage metadata", + "input_tokens", derefInt32(usage.InputTokens), + "output_tokens", derefInt32(usage.OutputTokens), + "cache_read_tokens", derefInt32(usage.CacheReadInputTokens), + "cache_write_tokens", derefInt32(usage.CacheWriteInputTokens), + "track_usage", a.trackUsage) + + if a.trackUsage { + response.Usage = &chat.Usage{ + InputTokens: int64(derefInt32(usage.InputTokens)), + OutputTokens: int64(derefInt32(usage.OutputTokens)), + CachedInputTokens: int64(derefInt32(usage.CacheReadInputTokens)), + CacheWriteTokens: int64(derefInt32(usage.CacheWriteInputTokens)), + } + } + } else { + slog.Debug("Bedrock stream: metadata event has no usage data") + } + } + + return response, nil +} + +// Close closes the stream +func (a *streamAdapter) Close() { + if a.stream != nil { + _ = a.stream.Close() + } +} + +// derefString safely dereferences a string pointer +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + +// derefInt32 safely dereferences an int32 pointer +func derefInt32(i *int32) int32 { + if i == nil { + return 0 + } + return *i +} diff --git a/pkg/model/provider/bedrock/client.go b/pkg/model/provider/bedrock/client.go new file mode 100644 index 000000000..3e8fec64a --- /dev/null +++ b/pkg/model/provider/bedrock/client.go @@ -0,0 +1,252 @@ +package bedrock + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + + "github.com/docker/cagent/pkg/chat" + "github.com/docker/cagent/pkg/config/latest" + "github.com/docker/cagent/pkg/environment" + "github.com/docker/cagent/pkg/model/provider/base" + "github.com/docker/cagent/pkg/model/provider/options" + "github.com/docker/cagent/pkg/tools" +) + +// Client represents a Bedrock client wrapper implementing provider.Provider +type Client struct { + base.Config + bedrockClient *bedrockruntime.Client +} + +// bearerTokenTransport adds Authorization header with bearer token to requests +type bearerTokenTransport struct { + token string + base http.RoundTripper +} + +func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "Bearer "+t.token) + return t.base.RoundTrip(req) +} + +// NewClient creates a new Bedrock client from the provided configuration +func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Provider, opts ...options.Opt) (*Client, error) { + if cfg == nil { + slog.Error("Bedrock client creation failed", "error", "model configuration is required") + return nil, errors.New("model configuration is required") + } + + if cfg.Provider != "amazon-bedrock" { + slog.Error("Bedrock client creation failed", "error", "model type must be 'amazon-bedrock'", "actual_type", cfg.Provider) + return nil, errors.New("model type must be 'amazon-bedrock'") + } + + var globalOptions options.ModelOptions + for _, opt := range opts { + opt(&globalOptions) + } + + // Check for bearer token - use token_key if specified, otherwise try AWS_BEARER_TOKEN_BEDROCK. + // Bearer token is optional: if not provided, falls back to standard AWS credential chain (SigV4). + // + // NOTE: Manual token handling is required because aws-sdk-go-v2's default credential chain + // does not recognize bearer tokens for Bedrock API keys. + // See: https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-use.html + var bearerToken string + if cfg.TokenKey != "" { + bearerToken, _ = env.Get(ctx, cfg.TokenKey) + if bearerToken == "" { + slog.Debug("Bedrock token_key configured but env var is empty, falling back to AWS credential chain", + "token_key", cfg.TokenKey) + } + } else { + bearerToken, _ = env.Get(ctx, "AWS_BEARER_TOKEN_BEDROCK") + } + + // Build AWS config using default credential chain + awsCfg, err := buildAWSConfig(ctx, cfg, env) + if err != nil { + slog.Error("Failed to build AWS config", "error", err) + return nil, fmt.Errorf("failed to build AWS config: %w", err) + } + + // Create Bedrock Runtime client with appropriate auth + var clientOpts []func(*bedrockruntime.Options) + + // Support custom endpoint for VPC endpoints or testing + if endpoint := getProviderOpt[string](cfg.ProviderOpts, "endpoint_url"); endpoint != "" { + clientOpts = append(clientOpts, func(o *bedrockruntime.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) + } + + // If bearer token is set, use it instead of SigV4 + if bearerToken != "" { + slog.Debug("Bedrock using bearer token authentication") + clientOpts = append(clientOpts, func(o *bedrockruntime.Options) { + // Use anonymous credentials to skip SigV4 signing + o.Credentials = aws.AnonymousCredentials{} + // Add bearer token via custom HTTP client + o.HTTPClient = &http.Client{ + Transport: &bearerTokenTransport{ + token: bearerToken, + base: http.DefaultTransport, + }, + } + }) + } + + bedrockClient := bedrockruntime.NewFromConfig(awsCfg, clientOpts...) + + slog.Debug("Bedrock client created successfully", "model", cfg.Model, "region", awsCfg.Region) + + return &Client{ + Config: base.Config{ + ModelConfig: *cfg, + ModelOptions: globalOptions, + Env: env, + }, + bedrockClient: bedrockClient, + }, nil +} + +// buildAWSConfig creates AWS config with proper credentials using the default credential chain +func buildAWSConfig(ctx context.Context, cfg *latest.ModelConfig, env environment.Provider) (aws.Config, error) { + var configOpts []func(*config.LoadOptions) error + + // Region from provider_opts or environment + region := getProviderOpt[string](cfg.ProviderOpts, "region") + if region == "" { + region, _ = env.Get(ctx, "AWS_REGION") + } + if region == "" { + region, _ = env.Get(ctx, "AWS_DEFAULT_REGION") + } + if region == "" { + region = "us-east-1" // Default region + } + configOpts = append(configOpts, config.WithRegion(region)) + + // Profile from provider_opts + if profile := getProviderOpt[string](cfg.ProviderOpts, "profile"); profile != "" { + configOpts = append(configOpts, config.WithSharedConfigProfile(profile)) + } + + // Load base config with default credential chain + awsCfg, err := config.LoadDefaultConfig(ctx, configOpts...) + if err != nil { + return aws.Config{}, fmt.Errorf("failed to load AWS config: %w", err) + } + + // Handle assume role if specified + if roleARN := getProviderOpt[string](cfg.ProviderOpts, "role_arn"); roleARN != "" { + stsClient := sts.NewFromConfig(awsCfg) + creds := stscreds.NewAssumeRoleProvider(stsClient, roleARN, func(o *stscreds.AssumeRoleOptions) { + if sessionName := getProviderOpt[string](cfg.ProviderOpts, "role_session_name"); sessionName != "" { + o.RoleSessionName = sessionName + } else { + o.RoleSessionName = "cagent-bedrock-session" + } + if externalID := getProviderOpt[string](cfg.ProviderOpts, "external_id"); externalID != "" { + o.ExternalID = aws.String(externalID) + } + }) + awsCfg.Credentials = aws.NewCredentialsCache(creds) + slog.Debug("Bedrock using assumed role", "role_arn", roleARN) + } + + return awsCfg, nil +} + +// CreateChatCompletionStream creates a streaming chat completion request +func (c *Client) CreateChatCompletionStream( + ctx context.Context, + messages []chat.Message, + requestTools []tools.Tool, +) (chat.MessageStream, error) { + slog.Debug("Creating Bedrock chat completion stream", + "model", c.ModelConfig.Model, + "message_count", len(messages), + "tool_count", len(requestTools)) + + if len(messages) == 0 { + return nil, errors.New("at least one message is required") + } + + // Build Converse input + input := c.buildConverseStreamInput(messages, requestTools) + + // Call ConverseStream + output, err := c.bedrockClient.ConverseStream(ctx, input) + if err != nil { + slog.Error("Bedrock ConverseStream failed", "error", err) + return nil, fmt.Errorf("bedrock converse stream failed: %w", err) + } + + trackUsage := c.ModelConfig.TrackUsage == nil || *c.ModelConfig.TrackUsage + return newStreamAdapter(output.GetStream(), c.ModelConfig.Model, trackUsage), nil +} + +// buildConverseStreamInput creates the ConverseStream input parameters +func (c *Client) buildConverseStreamInput(messages []chat.Message, requestTools []tools.Tool) *bedrockruntime.ConverseStreamInput { + input := &bedrockruntime.ConverseStreamInput{ + ModelId: aws.String(c.ModelConfig.Model), + } + + // Convert and set messages (excluding system) + input.Messages, input.System = convertMessages(messages) + + // Set inference configuration + input.InferenceConfig = c.buildInferenceConfig() + + // Convert and set tools + if len(requestTools) > 0 { + input.ToolConfig = convertToolConfig(requestTools) + } + + return input +} + +// buildInferenceConfig creates the inference configuration +func (c *Client) buildInferenceConfig() *types.InferenceConfiguration { + cfg := &types.InferenceConfiguration{} + + if c.ModelConfig.MaxTokens != nil && *c.ModelConfig.MaxTokens > 0 { + cfg.MaxTokens = aws.Int32(int32(*c.ModelConfig.MaxTokens)) + } + if c.ModelConfig.Temperature != nil { + cfg.Temperature = aws.Float32(float32(*c.ModelConfig.Temperature)) + } + if c.ModelConfig.TopP != nil { + cfg.TopP = aws.Float32(float32(*c.ModelConfig.TopP)) + } + + return cfg +} + +// getProviderOpt extracts a typed value from provider_opts +func getProviderOpt[T any](opts map[string]any, key string) T { + var zero T + if opts == nil { + return zero + } + v, ok := opts[key] + if !ok { + return zero + } + typed, ok := v.(T) + if !ok { + return zero + } + return typed +} diff --git a/pkg/model/provider/bedrock/client_test.go b/pkg/model/provider/bedrock/client_test.go new file mode 100644 index 000000000..e6c79e401 --- /dev/null +++ b/pkg/model/provider/bedrock/client_test.go @@ -0,0 +1,635 @@ +package bedrock + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/cagent/pkg/chat" + "github.com/docker/cagent/pkg/config/latest" + "github.com/docker/cagent/pkg/environment" + "github.com/docker/cagent/pkg/tools" +) + +func TestConvertMessages_UserText(t *testing.T) { + t.Parallel() + + msgs := []chat.Message{{ + Role: chat.MessageRoleUser, + Content: "Hello, world!", + }} + + bedrockMsgs, system := convertMessages(msgs) + + require.Len(t, bedrockMsgs, 1) + assert.Empty(t, system) + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role) + require.Len(t, bedrockMsgs[0].Content, 1) + + textBlock, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "Hello, world!", textBlock.Value) +} + +func TestConvertMessages_SystemExtraction(t *testing.T) { + t.Parallel() + + msgs := []chat.Message{ + {Role: chat.MessageRoleSystem, Content: "Be helpful"}, + {Role: chat.MessageRoleUser, Content: "Hi"}, + } + + bedrockMsgs, system := convertMessages(msgs) + + require.Len(t, bedrockMsgs, 1) // Only user message + require.Len(t, system, 1) // System extracted + + systemBlock, ok := system[0].(*types.SystemContentBlockMemberText) + require.True(t, ok) + assert.Equal(t, "Be helpful", systemBlock.Value) +} + +func TestConvertMessages_AssistantWithToolCalls(t *testing.T) { + t.Parallel() + + msgs := []chat.Message{{ + Role: chat.MessageRoleAssistant, + ToolCalls: []tools.ToolCall{{ + ID: "tool-1", + Type: "function", + Function: tools.FunctionCall{ + Name: "get_weather", + Arguments: `{"location":"NYC"}`, + }, + }}, + }} + + bedrockMsgs, _ := convertMessages(msgs) + + require.Len(t, bedrockMsgs, 1) + require.Len(t, bedrockMsgs[0].Content, 1) + + // Verify tool use block + toolUse, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberToolUse) + require.True(t, ok) + assert.Equal(t, "tool-1", *toolUse.Value.ToolUseId) + assert.Equal(t, "get_weather", *toolUse.Value.Name) +} + +func TestConvertMessages_ToolResult(t *testing.T) { + t.Parallel() + + msgs := []chat.Message{{ + Role: chat.MessageRoleTool, + ToolCallID: "tool-1", + Content: "Weather is sunny", + }} + + bedrockMsgs, _ := convertMessages(msgs) + + require.Len(t, bedrockMsgs, 1) + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role) + + // Verify tool result block + toolResult, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberToolResult) + require.True(t, ok) + assert.Equal(t, "tool-1", *toolResult.Value.ToolUseId) +} + +func TestConvertMessages_EmptyContent(t *testing.T) { + t.Parallel() + + msgs := []chat.Message{ + {Role: chat.MessageRoleUser, Content: ""}, + {Role: chat.MessageRoleUser, Content: " "}, + } + + bedrockMsgs, _ := convertMessages(msgs) + assert.Empty(t, bedrockMsgs) +} + +func TestConvertToolConfig(t *testing.T) { + t.Parallel() + + requestTools := []tools.Tool{{ + Name: "test_tool", + Description: "A test tool", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "arg1": map[string]any{"type": "string"}, + }, + }, + }} + + config := convertToolConfig(requestTools) + + require.NotNil(t, config) + require.Len(t, config.Tools, 1) + + toolSpec, ok := config.Tools[0].(*types.ToolMemberToolSpec) + require.True(t, ok) + assert.Equal(t, "test_tool", *toolSpec.Value.Name) + assert.Equal(t, "A test tool", *toolSpec.Value.Description) +} + +func TestConvertToolConfig_Empty(t *testing.T) { + t.Parallel() + + config := convertToolConfig(nil) + assert.Nil(t, config) + + config = convertToolConfig([]tools.Tool{}) + assert.Nil(t, config) +} + +func TestGetProviderOpt(t *testing.T) { + t.Parallel() + + opts := map[string]any{ + "region": "us-west-2", + "role_arn": "arn:aws:iam::123:role/Test", + "number": 42, + } + + assert.Equal(t, "us-west-2", getProviderOpt[string](opts, "region")) + assert.Empty(t, getProviderOpt[string](opts, "nonexistent")) + assert.Empty(t, getProviderOpt[string](nil, "region")) + assert.Equal(t, 42, getProviderOpt[int](opts, "number")) +} + +func TestConvertMessages_MultiContent(t *testing.T) { + t.Parallel() + + msgs := []chat.Message{{ + Role: chat.MessageRoleUser, + MultiContent: []chat.MessagePart{ + {Type: chat.MessagePartTypeText, Text: "First part"}, + {Type: chat.MessagePartTypeText, Text: "Second part"}, + }, + }} + + bedrockMsgs, _ := convertMessages(msgs) + + require.Len(t, bedrockMsgs, 1) + require.Len(t, bedrockMsgs[0].Content, 2) +} + +func TestConvertMessages_ConsecutiveToolResults(t *testing.T) { + t.Parallel() + + // Simulates scenario where assistant calls multiple tools and gets multiple results + msgs := []chat.Message{ + {Role: chat.MessageRoleUser, Content: "Do two things"}, + { + Role: chat.MessageRoleAssistant, + ToolCalls: []tools.ToolCall{ + {ID: "tool-1", Function: tools.FunctionCall{Name: "action1", Arguments: "{}"}}, + {ID: "tool-2", Function: tools.FunctionCall{Name: "action2", Arguments: "{}"}}, + }, + }, + {Role: chat.MessageRoleTool, ToolCallID: "tool-1", Content: "Result 1"}, + {Role: chat.MessageRoleTool, ToolCallID: "tool-2", Content: "Result 2"}, + {Role: chat.MessageRoleUser, Content: "Continue"}, + } + + bedrockMsgs, _ := convertMessages(msgs) + + // Expect: user, assistant, user (grouped tool results), user + require.Len(t, bedrockMsgs, 4) + + // First message: user text + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role) + + // Second message: assistant with tool calls + assert.Equal(t, types.ConversationRoleAssistant, bedrockMsgs[1].Role) + require.Len(t, bedrockMsgs[1].Content, 2) // Two tool use blocks + + // Third message: user with GROUPED tool results (critical fix!) + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[2].Role) + require.Len(t, bedrockMsgs[2].Content, 2) // Both tool results in single message + + // Verify both tool results are present + toolResult1, ok := bedrockMsgs[2].Content[0].(*types.ContentBlockMemberToolResult) + require.True(t, ok) + assert.Equal(t, "tool-1", *toolResult1.Value.ToolUseId) + + toolResult2, ok := bedrockMsgs[2].Content[1].(*types.ContentBlockMemberToolResult) + require.True(t, ok) + assert.Equal(t, "tool-2", *toolResult2.Value.ToolUseId) + + // Fourth message: user text + assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[3].Role) +} + +func TestBearerTokenTransport(t *testing.T) { + t.Parallel() + + // Create a test server to capture the Authorization header + var capturedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create transport with bearer token + transport := &bearerTokenTransport{ + token: "test-api-key-12345", + base: http.DefaultTransport, + } + + // Make a request through the transport + client := &http.Client{Transport: transport} + resp, err := client.Get(server.URL) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the Authorization header was set correctly + assert.Equal(t, "Bearer test-api-key-12345", capturedAuth) +} + +// Image URL conversion tests + +func TestConvertImageURL_NonDataURL(t *testing.T) { + t.Parallel() + + imageURL := &chat.MessageImageURL{URL: "https://example.com/image.png"} + result := convertImageURL(imageURL) + assert.Nil(t, result) +} + +func TestConvertImageURL_InvalidDataURLFormat(t *testing.T) { + t.Parallel() + + // Missing comma separator + imageURL := &chat.MessageImageURL{URL: "data:image/pngbase64invaliddata"} + result := convertImageURL(imageURL) + assert.Nil(t, result) +} + +func TestConvertImageURL_InvalidBase64(t *testing.T) { + t.Parallel() + + imageURL := &chat.MessageImageURL{URL: "data:image/png;base64,not-valid-base64!!!"} + result := convertImageURL(imageURL) + assert.Nil(t, result) +} + +func TestConvertImageURL_AllFormats(t *testing.T) { + t.Parallel() + + validBase64 := base64.StdEncoding.EncodeToString([]byte("fake image data")) + + testCases := []struct { + name string + mimeType string + expectedFormat types.ImageFormat + }{ + {"JPEG", "image/jpeg", types.ImageFormatJpeg}, + {"PNG", "image/png", types.ImageFormatPng}, + {"GIF", "image/gif", types.ImageFormatGif}, + {"WebP", "image/webp", types.ImageFormatWebp}, + {"Unknown defaults to JPEG", "image/bmp", types.ImageFormatJpeg}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + imageURL := &chat.MessageImageURL{ + URL: "data:" + tc.mimeType + ";base64," + validBase64, + } + result := convertImageURL(imageURL) + require.NotNil(t, result) + + imageBlock, ok := result.(*types.ContentBlockMemberImage) + require.True(t, ok) + assert.Equal(t, tc.expectedFormat, imageBlock.Value.Format) + }) + } +} + +func TestConvertImageURL_ValidImage(t *testing.T) { + t.Parallel() + + // Create a valid base64-encoded "image" + imageData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes + validBase64 := base64.StdEncoding.EncodeToString(imageData) + + imageURL := &chat.MessageImageURL{ + URL: "data:image/png;base64," + validBase64, + } + + result := convertImageURL(imageURL) + require.NotNil(t, result) + + imageBlock, ok := result.(*types.ContentBlockMemberImage) + require.True(t, ok) + assert.Equal(t, types.ImageFormatPng, imageBlock.Value.Format) + + // Verify decoded data matches + source, ok := imageBlock.Value.Source.(*types.ImageSourceMemberBytes) + require.True(t, ok) + assert.Equal(t, imageData, source.Value) +} + +// NewClient validation tests + +type mockEnvProvider struct { + values map[string]string +} + +func (m *mockEnvProvider) Get(_ context.Context, key string) (string, bool) { + if m.values == nil { + return "", false + } + v, ok := m.values[key] + return v, ok +} + +var _ environment.Provider = (*mockEnvProvider)(nil) + +func TestNewClient_NilConfig(t *testing.T) { + t.Parallel() + + _, err := NewClient(t.Context(), nil, &mockEnvProvider{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "model configuration is required") +} + +func TestNewClient_WrongProvider(t *testing.T) { + t.Parallel() + + cfg := &latest.ModelConfig{ + Provider: "openai", + Model: "gpt-4", + } + _, err := NewClient(t.Context(), cfg, &mockEnvProvider{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "model type must be 'amazon-bedrock'") +} + +// Interface compliance assertion +var _ chat.MessageStream = (*streamAdapter)(nil) + +// Additional getProviderOpt tests + +func TestGetProviderOpt_TypeMismatch(t *testing.T) { + t.Parallel() + + opts := map[string]any{ + "region": "us-west-2", // string + "number": 42, // int + "float": 3.14, // float64 + "bool": true, // bool + } + + // Request wrong type - should return zero value + t.Run("string as int returns zero", func(t *testing.T) { + t.Parallel() + result := getProviderOpt[int](opts, "region") + assert.Equal(t, 0, result) + }) + + t.Run("int as string returns empty", func(t *testing.T) { + t.Parallel() + result := getProviderOpt[string](opts, "number") + assert.Empty(t, result) + }) + + t.Run("bool as string returns empty", func(t *testing.T) { + t.Parallel() + result := getProviderOpt[string](opts, "bool") + assert.Empty(t, result) + }) +} + +// buildAWSConfig tests + +func TestBuildAWSConfig_DefaultRegion(t *testing.T) { + t.Parallel() + + cfg := &latest.ModelConfig{ + Provider: "amazon-bedrock", + Model: "anthropic.claude-v2", + ProviderOpts: map[string]any{}, + } + + env := &mockEnvProvider{values: map[string]string{}} + + awsCfg, err := buildAWSConfig(t.Context(), cfg, env) + require.NoError(t, err) + + // Default region should be us-east-1 + assert.Equal(t, "us-east-1", awsCfg.Region) +} + +func TestBuildAWSConfig_RegionFromProviderOpts(t *testing.T) { + t.Parallel() + + cfg := &latest.ModelConfig{ + Provider: "amazon-bedrock", + Model: "anthropic.claude-v2", + ProviderOpts: map[string]any{ + "region": "eu-west-1", + }, + } + + env := &mockEnvProvider{values: map[string]string{}} + + awsCfg, err := buildAWSConfig(t.Context(), cfg, env) + require.NoError(t, err) + + assert.Equal(t, "eu-west-1", awsCfg.Region) +} + +func TestBuildAWSConfig_RegionFromEnv(t *testing.T) { + t.Parallel() + + cfg := &latest.ModelConfig{ + Provider: "amazon-bedrock", + Model: "anthropic.claude-v2", + ProviderOpts: map[string]any{}, + } + + env := &mockEnvProvider{values: map[string]string{ + "AWS_REGION": "ap-northeast-1", + }} + + awsCfg, err := buildAWSConfig(t.Context(), cfg, env) + require.NoError(t, err) + + assert.Equal(t, "ap-northeast-1", awsCfg.Region) +} + +func TestBuildAWSConfig_ProviderOptsOverridesEnv(t *testing.T) { + t.Parallel() + + cfg := &latest.ModelConfig{ + Provider: "amazon-bedrock", + Model: "anthropic.claude-v2", + ProviderOpts: map[string]any{ + "region": "eu-central-1", + }, + } + + env := &mockEnvProvider{values: map[string]string{ + "AWS_REGION": "us-west-2", + }} + + awsCfg, err := buildAWSConfig(t.Context(), cfg, env) + require.NoError(t, err) + + // provider_opts should take precedence + assert.Equal(t, "eu-central-1", awsCfg.Region) +} + +// NewClient with valid config tests + +func TestNewClient_ValidConfig(t *testing.T) { + t.Parallel() + + cfg := &latest.ModelConfig{ + Provider: "amazon-bedrock", + Model: "anthropic.claude-v2", + ProviderOpts: map[string]any{ + "region": "us-east-1", + }, + } + + env := &mockEnvProvider{values: map[string]string{}} + + client, err := NewClient(t.Context(), cfg, env) + require.NoError(t, err) + require.NotNil(t, client) + + // Verify client was configured correctly + assert.Equal(t, "anthropic.claude-v2", client.ModelConfig.Model) + assert.Equal(t, "amazon-bedrock", client.ModelConfig.Provider) +} + +func TestNewClient_WithBearerToken(t *testing.T) { + t.Parallel() + + cfg := &latest.ModelConfig{ + Provider: "amazon-bedrock", + Model: "anthropic.claude-v2", + TokenKey: "MY_BEDROCK_TOKEN", + ProviderOpts: map[string]any{ + "region": "us-east-1", + }, + } + + env := &mockEnvProvider{values: map[string]string{ + "MY_BEDROCK_TOKEN": "test-bearer-token", + }} + + client, err := NewClient(t.Context(), cfg, env) + require.NoError(t, err) + require.NotNil(t, client) +} + +func TestNewClient_WithBearerTokenFromEnv(t *testing.T) { + t.Parallel() + + cfg := &latest.ModelConfig{ + Provider: "amazon-bedrock", + Model: "anthropic.claude-v2", + ProviderOpts: map[string]any{ + "region": "us-east-1", + }, + } + + env := &mockEnvProvider{values: map[string]string{ + "AWS_BEARER_TOKEN_BEDROCK": "env-bearer-token", + }} + + client, err := NewClient(t.Context(), cfg, env) + require.NoError(t, err) + require.NotNil(t, client) +} + +// Usage tracking tests + +func TestDerefInt32(t *testing.T) { + t.Parallel() + + t.Run("nil returns 0", func(t *testing.T) { + t.Parallel() + assert.Equal(t, int32(0), derefInt32(nil)) + }) + + t.Run("non-nil returns value", func(t *testing.T) { + t.Parallel() + val := int32(42) + assert.Equal(t, int32(42), derefInt32(&val)) + }) + + t.Run("zero value returns 0", func(t *testing.T) { + t.Parallel() + val := int32(0) + assert.Equal(t, int32(0), derefInt32(&val)) + }) +} + +func TestDerefString(t *testing.T) { + t.Parallel() + + t.Run("nil returns empty", func(t *testing.T) { + t.Parallel() + assert.Empty(t, derefString(nil)) + }) + + t.Run("non-nil returns value", func(t *testing.T) { + t.Parallel() + val := "hello" + assert.Equal(t, "hello", derefString(&val)) + }) +} + +// Test that usage values are properly converted from int32 pointers to int64 +func TestUsageConversion(t *testing.T) { + t.Parallel() + + // Simulate what happens when we convert AWS SDK values + inputTokens := int32(1500) + outputTokens := int32(500) + cacheReadTokens := int32(100) + cacheWriteTokens := int32(50) + + usage := &chat.Usage{ + InputTokens: int64(derefInt32(&inputTokens)), + OutputTokens: int64(derefInt32(&outputTokens)), + CachedInputTokens: int64(derefInt32(&cacheReadTokens)), + CacheWriteTokens: int64(derefInt32(&cacheWriteTokens)), + } + + assert.Equal(t, int64(1500), usage.InputTokens) + assert.Equal(t, int64(500), usage.OutputTokens) + assert.Equal(t, int64(100), usage.CachedInputTokens) + assert.Equal(t, int64(50), usage.CacheWriteTokens) +} + +// Test that nil usage pointers result in zero values (not panics) +func TestUsageConversion_NilSafe(t *testing.T) { + t.Parallel() + + // Simulate nil pointers from AWS SDK + usage := &chat.Usage{ + InputTokens: int64(derefInt32(nil)), + OutputTokens: int64(derefInt32(nil)), + CachedInputTokens: int64(derefInt32(nil)), + CacheWriteTokens: int64(derefInt32(nil)), + } + + assert.Equal(t, int64(0), usage.InputTokens) + assert.Equal(t, int64(0), usage.OutputTokens) + assert.Equal(t, int64(0), usage.CachedInputTokens) + assert.Equal(t, int64(0), usage.CacheWriteTokens) +} diff --git a/pkg/model/provider/bedrock/convert.go b/pkg/model/provider/bedrock/convert.go new file mode 100644 index 000000000..a4c90a8fd --- /dev/null +++ b/pkg/model/provider/bedrock/convert.go @@ -0,0 +1,253 @@ +package bedrock + +import ( + "encoding/base64" + "encoding/json" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/document" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + + "github.com/docker/cagent/pkg/chat" + "github.com/docker/cagent/pkg/tools" +) + +// convertMessages converts chat.Messages to Bedrock Message format +// Returns (messages, system content blocks) +// +// Bedrock's Converse API requires that: +// 1. Tool results must immediately follow the assistant message with tool_use +// 2. Multiple consecutive tool results must be grouped into a single user message +func convertMessages(messages []chat.Message) ([]types.Message, []types.SystemContentBlock) { + var bedrockMessages []types.Message + var systemBlocks []types.SystemContentBlock + + for i := 0; i < len(messages); i++ { + msg := &messages[i] + + switch msg.Role { + case chat.MessageRoleSystem: + // Extract system messages into separate system blocks + if len(msg.MultiContent) > 0 { + for _, part := range msg.MultiContent { + if part.Type == chat.MessagePartTypeText && strings.TrimSpace(part.Text) != "" { + systemBlocks = append(systemBlocks, &types.SystemContentBlockMemberText{ + Value: part.Text, + }) + } + } + } else if strings.TrimSpace(msg.Content) != "" { + systemBlocks = append(systemBlocks, &types.SystemContentBlockMemberText{ + Value: msg.Content, + }) + } + + case chat.MessageRoleUser: + contentBlocks := convertUserContent(msg) + if len(contentBlocks) > 0 { + bedrockMessages = append(bedrockMessages, types.Message{ + Role: types.ConversationRoleUser, + Content: contentBlocks, + }) + } + + case chat.MessageRoleAssistant: + contentBlocks := convertAssistantContent(msg) + if len(contentBlocks) > 0 { + bedrockMessages = append(bedrockMessages, types.Message{ + Role: types.ConversationRoleAssistant, + Content: contentBlocks, + }) + } + + case chat.MessageRoleTool: + // Group consecutive tool results into a single user message + // This satisfies Bedrock's requirement that tool results immediately follow + // the assistant message with tool_use blocks + var toolResultBlocks []types.ContentBlock + j := i + for j < len(messages) && messages[j].Role == chat.MessageRoleTool { + if messages[j].ToolCallID != "" { + toolResultBlocks = append(toolResultBlocks, &types.ContentBlockMemberToolResult{ + Value: types.ToolResultBlock{ + ToolUseId: aws.String(messages[j].ToolCallID), + Content: []types.ToolResultContentBlock{ + &types.ToolResultContentBlockMemberText{ + Value: messages[j].Content, + }, + }, + }, + }) + } + j++ + } + if len(toolResultBlocks) > 0 { + bedrockMessages = append(bedrockMessages, types.Message{ + Role: types.ConversationRoleUser, + Content: toolResultBlocks, + }) + } + // Skip the messages we already processed + i = j - 1 + } + } + + return bedrockMessages, systemBlocks +} + +// convertUserContent converts user message content to Bedrock ContentBlocks +func convertUserContent(msg *chat.Message) []types.ContentBlock { + var blocks []types.ContentBlock + + if len(msg.MultiContent) > 0 { + for _, part := range msg.MultiContent { + switch part.Type { + case chat.MessagePartTypeText: + if strings.TrimSpace(part.Text) != "" { + blocks = append(blocks, &types.ContentBlockMemberText{ + Value: part.Text, + }) + } + case chat.MessagePartTypeImageURL: + if part.ImageURL != nil { + if imageBlock := convertImageURL(part.ImageURL); imageBlock != nil { + blocks = append(blocks, imageBlock) + } + } + } + } + } else if strings.TrimSpace(msg.Content) != "" { + blocks = append(blocks, &types.ContentBlockMemberText{ + Value: msg.Content, + }) + } + + return blocks +} + +// convertImageURL converts an image URL to Bedrock ImageBlock +func convertImageURL(imageURL *chat.MessageImageURL) types.ContentBlock { + if !strings.HasPrefix(imageURL.URL, "data:") { + return nil + } + + parts := strings.SplitN(imageURL.URL, ",", 2) + if len(parts) != 2 { + return nil + } + + // Decode base64 data + imageData, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return nil + } + + // Determine format from media type + var format types.ImageFormat + switch { + case strings.Contains(parts[0], "image/jpeg"): + format = types.ImageFormatJpeg + case strings.Contains(parts[0], "image/png"): + format = types.ImageFormatPng + case strings.Contains(parts[0], "image/gif"): + format = types.ImageFormatGif + case strings.Contains(parts[0], "image/webp"): + format = types.ImageFormatWebp + default: + format = types.ImageFormatJpeg + } + + return &types.ContentBlockMemberImage{ + Value: types.ImageBlock{ + Format: format, + Source: &types.ImageSourceMemberBytes{ + Value: imageData, + }, + }, + } +} + +// convertAssistantContent converts assistant message to Bedrock ContentBlocks +func convertAssistantContent(msg *chat.Message) []types.ContentBlock { + var blocks []types.ContentBlock + + // Add text content if present + if strings.TrimSpace(msg.Content) != "" { + blocks = append(blocks, &types.ContentBlockMemberText{ + Value: msg.Content, + }) + } + + // Add tool use blocks for tool calls + for _, tc := range msg.ToolCalls { + var input map[string]any + if tc.Function.Arguments != "" { + _ = json.Unmarshal([]byte(tc.Function.Arguments), &input) + } + if input == nil { + input = make(map[string]any) + } + + // Convert input map to document (required by Bedrock) + inputDoc := mapToDocument(input) + + blocks = append(blocks, &types.ContentBlockMemberToolUse{ + Value: types.ToolUseBlock{ + ToolUseId: aws.String(tc.ID), + Name: aws.String(tc.Function.Name), + Input: inputDoc, + }, + }) + } + + return blocks +} + +// mapToDocument converts a map to Bedrock document format +func mapToDocument(m map[string]any) document.Interface { + return document.NewLazyDocument(m) +} + +// convertToolConfig converts tools to Bedrock ToolConfiguration +func convertToolConfig(requestTools []tools.Tool) *types.ToolConfiguration { + if len(requestTools) == 0 { + return nil + } + + toolSpecs := make([]types.Tool, len(requestTools)) + for i, tool := range requestTools { + // Convert parameters to JSON schema format + schema := convertToolSchema(tool.Parameters) + + toolSpecs[i] = &types.ToolMemberToolSpec{ + Value: types.ToolSpecification{ + Name: aws.String(tool.Name), + Description: aws.String(tool.Description), + InputSchema: &types.ToolInputSchemaMemberJson{ + Value: schema, + }, + }, + } + } + + return &types.ToolConfiguration{ + Tools: toolSpecs, + // Auto tool choice lets the model decide + ToolChoice: &types.ToolChoiceMemberAuto{ + Value: types.AutoToolChoice{}, + }, + } +} + +// convertToolSchema converts tool parameters to Bedrock-compatible JSON schema +func convertToolSchema(params any) document.Interface { + schema, err := tools.SchemaToMap(params) + if err != nil { + schema = map[string]any{ + "type": "object", + "properties": map[string]any{}, + } + } + return document.NewLazyDocument(schema) +} diff --git a/pkg/model/provider/provider.go b/pkg/model/provider/provider.go index b24706325..6b69a90eb 100644 --- a/pkg/model/provider/provider.go +++ b/pkg/model/provider/provider.go @@ -11,6 +11,7 @@ import ( "github.com/docker/cagent/pkg/environment" "github.com/docker/cagent/pkg/model/provider/anthropic" "github.com/docker/cagent/pkg/model/provider/base" + "github.com/docker/cagent/pkg/model/provider/bedrock" "github.com/docker/cagent/pkg/model/provider/dmr" "github.com/docker/cagent/pkg/model/provider/gemini" "github.com/docker/cagent/pkg/model/provider/openai" @@ -186,6 +187,9 @@ func createDirectProvider(ctx context.Context, cfg *latest.ModelConfig, env envi case "dmr": return dmr.NewClient(ctx, enhancedCfg, opts...) + case "amazon-bedrock": + return bedrock.NewClient(ctx, enhancedCfg, env, opts...) + default: slog.Error("Unknown provider type", "type", providerType) return nil, fmt.Errorf("unknown provider type: %s", providerType) diff --git a/pkg/teamloader/teamloader_test.go b/pkg/teamloader/teamloader_test.go index 58739a58f..cea028a6c 100644 --- a/pkg/teamloader/teamloader_test.go +++ b/pkg/teamloader/teamloader_test.go @@ -14,6 +14,12 @@ import ( "github.com/docker/cagent/pkg/config/latest" ) +// skipExamples contains example files that require cloud-specific configurations +// (e.g., AWS profiles, GCP credentials) that can't be mocked with dummy env vars. +var skipExamples = map[string]string{ + "pr-reviewer-bedrock.yaml": "requires AWS profile configuration", +} + func collectExamples(t *testing.T) []string { t.Helper() @@ -23,6 +29,10 @@ func collectExamples(t *testing.T) []string { return err } if !d.IsDir() && filepath.Ext(path) == ".yaml" { + if reason, skip := skipExamples[filepath.Base(path)]; skip { + t.Logf("Skipping %s: %s", path, reason) + return nil + } files = append(files, path) } return nil