Write

package ai

import (
	"context"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/aws"
	awsconfig "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"
)

type BedrockConfig struct {
	Region  string `toml:"region"`
	ModelID string `toml:"model_id"`

	// Profile, when set, selects a named profile from the shared AWS config
	// and credentials files instead of the default chain. Handy for local
	// dev; leave empty in prod so IRSA / Pod Identity take over.
	Profile string `toml:"profile"`

	// RoleARN, when set, wraps the resolved base credentials with an
	// AssumeRole call. Use for cross-account setups where IRSA grants a
	// role in one account and Bedrock lives in another. Base credentials
	// (from IRSA, profile, env, etc.) must be allowed to sts:AssumeRole it.
	RoleARN         string `toml:"role_arn"`
	RoleSessionName string `toml:"role_session_name"`
	ExternalID      string `toml:"external_id"`
}

type Bedrock struct {
	cfg    BedrockConfig
	client *bedrockruntime.Client
}

func NewBedrock(cfg BedrockConfig) (*Bedrock, error) {
	if cfg.ModelID == "" {
		cfg.ModelID = "us.anthropic.claude-sonnet-4-6-v1:0"
	}

	opts := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(cfg.Region)}
	if cfg.Profile != "" {
		opts = append(opts, awsconfig.WithSharedConfigProfile(cfg.Profile))
	}

	awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), opts...)
	if err != nil {
		return nil, fmt.Errorf("aws config: %w", err)
	}

	if cfg.RoleARN != "" {
		provider := stscreds.NewAssumeRoleProvider(sts.NewFromConfig(awsCfg), cfg.RoleARN, func(o *stscreds.AssumeRoleOptions) {
			if cfg.RoleSessionName != "" {
				o.RoleSessionName = cfg.RoleSessionName
			}
			if cfg.ExternalID != "" {
				o.ExternalID = aws.String(cfg.ExternalID)
			}
		})
		awsCfg.Credentials = aws.NewCredentialsCache(provider)
	}

	return &Bedrock{cfg: cfg, client: bedrockruntime.NewFromConfig(awsCfg)}, nil
}

func (b *Bedrock) Assess(ctx context.Context, req ScoreRequest) (*Assessment, error) {
	out, err := b.client.Converse(ctx, &bedrockruntime.ConverseInput{
		ModelId: aws.String(b.cfg.ModelID),
		System: []types.SystemContentBlock{
			&types.SystemContentBlockMemberText{Value: SystemPrompt},
		},
		Messages: []types.Message{{
			Role: types.ConversationRoleUser,
			Content: []types.ContentBlock{
				&types.ContentBlockMemberText{Value: UserPrompt(req)},
			},
		}},
		InferenceConfig: &types.InferenceConfiguration{
			MaxTokens: aws.Int32(1024),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("bedrock converse: %w", err)
	}

	msg, ok := out.Output.(*types.ConverseOutputMemberMessage)
	if !ok {
		return nil, fmt.Errorf("bedrock: unexpected output variant %T", out.Output)
	}
	for _, block := range msg.Value.Content {
		if t, ok := block.(*types.ContentBlockMemberText); ok {
			return parseAssessment(t.Value)
		}
	}
	return nil, fmt.Errorf("no text content in bedrock response")
}


The file /Users/steve.briggs/projects/operations/apps/ruth/ai/bedrock.go has been updated successfully.

package ai

import (
	"context"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/aws"
	awsconfig "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"
)

type BedrockConfig struct {
	Region  string `toml:"region"`
	ModelID string `toml:"model_id"`

	// Profile, when set, selects a named profile from the shared AWS config
	// and credentials files instead of the default chain. Handy for local
	// dev; leave empty in prod so IRSA / Pod Identity take over.
	Profile string `toml:"profile"`

	// RoleARN, when set, wraps the resolved base credentials with an
	// AssumeRole call. Use for cross-account setups where IRSA grants a
	// role in one account and Bedrock lives in another. Base credentials
	// (from IRSA, profile, env, etc.) must be allowed to sts:AssumeRole it.
	RoleARN         string `toml:"role_arn"`
	RoleSessionName string `toml:"role_session_name"`
	ExternalID      string `toml:"external_id"`
}

type Bedrock struct {
	cfg    BedrockConfig
	client *bedrockruntime.Client
}

func NewBedrock(cfg BedrockConfig) (*Bedrock, error) {
	if cfg.ModelID == "" {
		cfg.ModelID = "us.anthropic.claude-sonnet-4-6-v1:0"
	}

	opts := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(cfg.Region)}
	if cfg.Profile != "" {
		opts = append(opts, awsconfig.WithSharedConfigProfile(cfg.Profile))
	}

	awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), opts...)
	if err != nil {
		return nil, fmt.Errorf("aws config: %w", err)
	}

	if cfg.RoleARN != "" {
		provider := stscreds.NewAssumeRoleProvider(sts.NewFromConfig(awsCfg), cfg.RoleARN, func(o *stscreds.AssumeRoleOptions) {
			if cfg.RoleSessionName != "" {
				o.RoleSessionName = cfg.RoleSessionName
			}
			if cfg.ExternalID != "" {
				o.ExternalID = aws.String(cfg.ExternalID)
			}
		})
		awsCfg.Credentials = aws.NewCredentialsCache(provider)
	}

	return &Bedrock{cfg: cfg, client: bedrockruntime.NewFromConfig(awsCfg)}, nil
}

func (b *Bedrock) Assess(ctx context.Context, req ScoreRequest) (*Assessment, error) {
	out, err := b.client.Converse(ctx, &bedrockruntime.ConverseInput{
		ModelId: aws.String(b.cfg.ModelID),
		System: []types.SystemContentBlock{
			&types.SystemContentBlockMemberText{Value: SystemPrompt},
		},
		Messages: []types.Message{{
			Role: types.ConversationRoleUser,
			Content: []types.ContentBlock{
				&types.ContentBlockMemberText{Value: UserPrompt(req)},
			},
		}},
		InferenceConfig: &types.InferenceConfiguration{
			MaxTokens: aws.Int32(1024),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("bedrock converse: %w", err)
	}

	msg, ok := out.Output.(*types.ConverseOutputMemberMessage)
	if !ok {
		return nil, fmt.Errorf("bedrock: unexpected output variant %T", out.Output)
	}
	for _, block := range msg.Value.Content {
		if t, ok := block.(*types.ContentBlockMemberText); ok {
			return parseAssessment(t.Value)
		}
	}
	return nil, fmt.Errorf("no text content in bedrock response")
}

-  body, err := json.Marshal(map[string]any{

-    "anthropic_version": "bedrock-2023-05-31",

-      {"role": "user", "content": UserPrompt(req)},

+  out, err := b.client.Converse(ctx, &bedrockruntime.ConverseInput{

+    ModelId: aws.String(b.cfg.ModelID),

+    System: []types.SystemContentBlock{

+      &types.SystemContentBlockMemberText{Value: SystemPrompt},

+      Role: types.ConversationRoleUser,

+        &types.ContentBlockMemberText{Value: UserPrompt(req)},

+    InferenceConfig: &types.InferenceConfiguration{

+    return nil, fmt.Errorf("bedrock converse: %w", err)

-  out, err := b.client.InvokeModel(ctx, &bedrockruntime.InvokeModelInput{

-    ModelId:     aws.String(b.cfg.ModelID),

-    ContentType: aws.String("application/json"),

-    Accept:      aws.String("application/json"),

-    return nil, fmt.Errorf("bedrock invoke: %w", err)

+  msg, ok := out.Output.(*types.ConverseOutputMemberMessage)

+    return nil, fmt.Errorf("bedrock: unexpected output variant %T", out.Output)

-  if err := json.Unmarshal(out.Body, &parsed); err != nil {

-    return nil, fmt.Errorf("bedrock decode: %w", err)

+  for _, block := range msg.Value.Content {

+    if t, ok := block.(*types.ContentBlockMemberText); ok {

package ai

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/aws"
	awsconfig "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/sts"
)

type BedrockConfig struct {
	Region  string `toml:"region"`
	ModelID string `toml:"model_id"`

	// Profile, when set, selects a named profile from the shared AWS config
	// and credentials files instead of the default chain. Handy for local
	// dev; leave empty in prod so IRSA / Pod Identity take over.
	Profile string `toml:"profile"`

	// RoleARN, when set, wraps the resolved base credentials with an
	// AssumeRole call. Use for cross-account setups where IRSA grants a
	// role in one account and Bedrock lives in another. Base credentials
	// (from IRSA, profile, env, etc.) must be allowed to sts:AssumeRole it.
	RoleARN         string `toml:"role_arn"`
	RoleSessionName string `toml:"role_session_name"`
	ExternalID      string `toml:"external_id"`
}

type Bedrock struct {
	cfg    BedrockConfig
	client *bedrockruntime.Client
}

func NewBedrock(cfg BedrockConfig) (*Bedrock, error) {
	if cfg.ModelID == "" {
		cfg.ModelID = "us.anthropic.claude-sonnet-4-6-v1:0"
	}

	opts := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(cfg.Region)}
	if cfg.Profile != "" {
		opts = append(opts, awsconfig.WithSharedConfigProfile(cfg.Profile))
	}

	awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), opts...)
	if err != nil {
		return nil, fmt.Errorf("aws config: %w", err)
	}

	if cfg.RoleARN != "" {
		provider := stscreds.NewAssumeRoleProvider(sts.NewFromConfig(awsCfg), cfg.RoleARN, func(o *stscreds.AssumeRoleOptions) {
			if cfg.RoleSessionName != "" {
				o.RoleSessionName = cfg.RoleSessionName
			}
			if cfg.ExternalID != "" {
				o.ExternalID = aws.String(cfg.ExternalID)
			}
		})
		awsCfg.Credentials = aws.NewCredentialsCache(provider)
	}

	return &Bedrock{cfg: cfg, client: bedrockruntime.NewFromConfig(awsCfg)}, nil
}

func (b *Bedrock) Assess(ctx context.Context, req ScoreRequest) (*Assessment, error) {
	body, err := json.Marshal(map[string]any{
		"anthropic_version": "bedrock-2023-05-31",
		"max_tokens":        1024,
		"system":            SystemPrompt,
		"messages": []map[string]any{
			{"role": "user", "content": UserPrompt(req)},
		},
	})
	if err != nil {
		return nil, err
	}
	out, err := b.client.InvokeModel(ctx, &bedrockruntime.InvokeModelInput{
		ModelId:     aws.String(b.cfg.ModelID),
		ContentType: aws.String("application/json"),
		Accept:      aws.String("application/json"),
		Body:        body,
	})
	if err != nil {
		return nil, fmt.Errorf("bedrock invoke: %w", err)
	}
	var parsed struct {
		Content []struct {
			Text string `json:"text"`
			Type string `json:"type"`
		} `json:"content"`
	}
	if err := json.Unmarshal(out.Body, &parsed); err != nil {
		return nil, fmt.Errorf("bedrock decode: %w", err)
	}
	for _, c := range parsed.Content {
		if c.Type == "text" {
			return parseAssessment(c.Text)
		}
	}
	return nil, fmt.Errorf("no text content in bedrock response")
}


Building to confirm it compiles.


Bash

Compile ruth


(Bash completed with no output)


Vet ruth


yes swap ai/berdrock.go from InvokeModel to the Converse API


Clean compile, clean vet. Summary of what changed in `ai/bedrock.go`:

- Dropped `encoding/json` — no more hand-rolled request envelope.
- Added the `bedrockruntime/types` import for the Converse type union.
- `Assess` now calls `client.Converse` with typed `System` / `Messages` / `InferenceConfig`. The `anthropic_version` / `max_tokens` keys are gone; AWS handles that translation per provider.
- Response is read by type-asserting the `ConverseOutput` union to `*ConverseOutputMemberMessage`, then walking content blocks for the first `*ContentBlockMemberText` — same downstream `parseAssessment` call as before.
- Error wrap renamed `bedrock invoke:` → `bedrock converse:` so future log lines tell you which API was in play.

Re-run with whichever Anthropic model ID you pinned — the Converse API should stop rejecting payloads regardless of whether you point it at a Messages-API or a legacy completions model.


Goal was getting ruth's local stack working against AWS Bedrock. Just swapped ai/bedrock.go from InvokeModel to the Converse API; it builds clean. Next: re-run with your pinned Anthropic model ID and confirm scoring succeeds. (disable recaps in /config)


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/exit</command-name>
            <command-message>exit</command-message>
            <command-args></command-args>


<local-command-stdout>See ya!</local-command-stdout>