Edit

// Evaluator calls Claude to assign a risk score to a Teleport access request.
type Evaluator struct {
	client *anthropic.Client
	model  string
}

func New(apiKey, model string) *Evaluator {
	return &Evaluator{
		client: anthropic.NewClient(option.WithAPIKey(apiKey)),
		model:  model,
	}
}

// Evaluator calls Claude to assign a risk score to a Teleport access request.
type Evaluator struct {
	client anthropic.Client
	model  string
}

func New(apiKey, model string) *Evaluator {
	return &Evaluator{
		client: anthropic.NewClient(option.WithAPIKey(apiKey)),
		model:  model,
	}
}


The file /Users/steve.briggs/projects/operations/services/televibe/internal/evaluator/evaluator.go has been updated successfully.

// Evaluator calls Claude to assign a risk score to a Teleport access request.
type Evaluator struct {
	client anthropic.Client
	model  string
}

func New(apiKey, model string) *Evaluator {
	return &Evaluator{
		client: anthropic.NewClient(option.WithAPIKey(apiKey)),
		model:  model,
	}
}

package evaluator

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

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/option"
)

// jiraPattern matches CUSTX-NNNN and ITSM-NNNN ticket references (case-insensitive).
var jiraPattern = regexp.MustCompile(`(?i)\b(CUSTX|ITSM)-\d+\b`)

// RiskLevel represents the severity of an access request.
type RiskLevel string

const (
	RiskLow    RiskLevel = "low"
	RiskMedium RiskLevel = "medium"
	RiskHigh   RiskLevel = "high"
)

// Assessment is the structured output of a Claude risk evaluation.
type Assessment struct {
	JIRATicket         string    `json:"jira_ticket"`
	HasJIRATicket      bool      `json:"has_jira_ticket"`
	DescriptionQuality string    `json:"description_quality"`
	RiskScore          int       `json:"risk_score"`
	RiskLevel          RiskLevel `json:"risk_level"`
	Reasoning          string    `json:"reasoning"`
}

// Request holds the access request fields that the evaluator needs.
type Request struct {
	ID     string
	User   string
	Roles  []string
	Reason string
}

// Evaluator calls Claude to assign a risk score to a Teleport access request.
type Evaluator struct {
	client *anthropic.Client
	model  string
}

func New(apiKey, model string) *Evaluator {
	return &Evaluator{
		client: anthropic.NewClient(option.WithAPIKey(apiKey)),
		model:  model,
	}
}

func (e *Evaluator) Evaluate(ctx context.Context, req Request) (*Assessment, error) {
	tickets := jiraPattern.FindAllString(req.Reason, -1)
	prompt := buildPrompt(req.User, req.Roles, req.Reason, tickets)

	msg, err := e.client.Messages.New(ctx, anthropic.MessageNewParams{
		Model:     e.model,
		MaxTokens: 512,
		System: []anthropic.TextBlockParam{
			{Text: "You are a security risk evaluator for Teleport access requests. Respond only with valid JSON."},
		},
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("claude API: %w", err)
	}

	if len(msg.Content) == 0 {
		return nil, fmt.Errorf("empty response from claude")
	}

	text := strings.TrimSpace(msg.Content[0].Text)
	// Strip markdown code fences that Claude sometimes adds.
	if strings.HasPrefix(text, "```") {
		if i := strings.Index(text[3:], "\n"); i >= 0 {
			text = strings.TrimSuffix(strings.TrimSpace(text[3+i+1:]), "```")
		}
	}

	var a Assessment
	if err := json.Unmarshal([]byte(text), &a); err != nil {
		return nil, fmt.Errorf("parsing response: %w (raw: %s)", err, text)
	}

	return &a, nil
}

func buildPrompt(user string, roles []string, reason string, tickets []string) string {
	ticketInfo := "none"
	if len(tickets) > 0 {
		ticketInfo = strings.Join(tickets, ", ")
	}

	return fmt.Sprintf(`You are a security risk evaluator for Teleport access requests. Respond ONLY with a valid JSON object — no explanation, no markdown.

Access request details:
  User:            %s
  Requested roles: %s
  Reason provided: %q
  JIRA tickets:    %s

Risk scoring rules:
  1–3  (low)    — valid JIRA ticket (CUSTX-NNN or ITSM-NNN) AND a clear description with business justification
  4–6  (medium) — either a valid JIRA ticket OR a clear description, but not both
  7–10 (high)   — no JIRA ticket AND no/vague description (single words or generic phrases like "need access" score 9–10)

Return exactly this JSON shape:
{
  "jira_ticket":          "<ticket ID or null>",
  "has_jira_ticket":      <true|false>,
  "description_quality":  "<none|poor|fair|good|excellent>",
  "risk_score":           <integer 1–10>,
  "risk_level":           "<low|medium|high>",
  "reasoning":            "<one concise sentence>"
}`,
		user,
		strings.Join(roles, ", "),
		reason,
		ticketInfo,
	)
}


Bash

Build televibe again


(Bash completed with no output)


Clean build. Run the tests:


Run all televibe tests


?   	github.com/immuta/operations/services/televibe	[no test files]
ok  	github.com/immuta/operations/services/televibe/internal/config	0.450s
ok  	github.com/immuta/operations/services/televibe/internal/evaluator	0.677s
?   	github.com/immuta/operations/services/televibe/internal/watcher	[no test files]


All tests pass. Let me do a final review of the complete file set:


find /Users/steve.briggs/projects/operations/services/televibe -type f | sort

List all televibe files


Here's what was built — compiles clean, all tests pass.

---

## `televibe` — structure

```
televibe/
├── Dockerfile
├── go.mod / go.sum
├── main.go
└── internal/
    ├── config/config.go          # env-based config
    ├── evaluator/evaluator.go    # Claude risk scoring
    └── watcher/watcher.go        # Teleport event stream
```

---

## How it works

**`watcher`** connects to Teleport via an identity file, opens a `NewWatcher` on `KindAccessRequest`, and yields only `PENDING` requests onto a buffered channel. It auto-reconnects on disconnect with a 5 s backoff.

**`evaluator`** sends each request to Claude (`claude-sonnet-4-6` by default) with a structured prompt that instructs it to:
- Detect JIRA ticket references (`CUSTX-NNN` or `ITSM-NNN`) — pre-scanned with regex before the API call
- Assess description quality (`none / poor / fair / good / excellent`)
- Return a **risk score 1–10** with a `low / medium / high` label and one-sentence reasoning

Scoring rubric enforced in the prompt:
| Score | Criteria |
|-------|----------|
| 1–3 low | Valid JIRA ticket **AND** clear description |
| 4–6 medium | JIRA ticket **OR** clear description, not both |
| 7–10 high | Neither — or vague/missing reason |

**`main`** fans requests into goroutines and logs each assessment as structured JSON via `log/slog`.

---

## Required env vars

```
TELEPORT_ADDR            teleport.example.com:443
TELEPORT_IDENTITY_FILE   /path/to/identity.pem   # from: tctl auth sign --user=televibe
ANTHROPIC_API_KEY        sk-ant-...
CLAUDE_MODEL             claude-sonnet-4-6        # optional, this is the default
```


can we also add the option to use aws bedrock instead of anthropic?


Let me check whether the SDK's built-in Bedrock client is available in v1.37.0.


Check Bedrock subpackage in Anthropic SDK


.r--r--r-- steve.briggs staff 8.2 KB Wed Apr 22 15:20:36 2026 bedrock.go
.r--r--r-- steve.briggs staff 7.7 KB Wed Apr 22 15:20:36 2026 bedrock_test.go
.r--r--r-- steve.briggs staff 4.9 KB Wed Apr 22 15:20:36 2026 bedrockmantle.go
.r--r--r-- steve.briggs staff 3.8 KB Wed Apr 22 15:20:36 2026 bedrockmantle_live_test.go
dr-xr-xr-x steve.briggs staff 224 B  Wed Apr 22 15:20:36 2026 .
.r--r--r-- steve.briggs staff  14 KB Wed Apr 22 15:20:36 2026 bedrockmantle_test.go
dr-xr-xr-x steve.briggs staff 2.5 KB Wed Apr 22 15:20:36 2026 ..


1	package evaluator
2
3	import (
4		"context"
5		"encoding/json"
6		"fmt"
7		"regexp"
8		"strings"
9
10		"github.com/anthropics/anthropic-sdk-go"
11		"github.com/anthropics/anthropic-sdk-go/option"
12	)
13
14	// jiraPattern matches CUSTX-NNNN and ITSM-NNNN ticket references (case-insensitive).
15	var jiraPattern = regexp.MustCompile(`(?i)\b(CUSTX|ITSM)-\d+\b`)
16
17	// RiskLevel represents the severity of an access request.
18	type RiskLevel string
19
20	const (
21		RiskLow    RiskLevel = "low"
22		RiskMedium RiskLevel = "medium"
23		RiskHigh   RiskLevel = "high"
24	)
25
26	// Assessment is the structured output of a Claude risk evaluation.
27	type Assessment struct {
28		JIRATicket         string    `json:"jira_ticket"`
29		HasJIRATicket      bool      `json:"has_jira_ticket"`
30		DescriptionQuality string    `json:"description_quality"`
31		RiskScore          int       `json:"risk_score"`
32		RiskLevel          RiskLevel `json:"risk_level"`
33		Reasoning          string    `json:"reasoning"`
34	}
35
36	// Request holds the access request fields that the evaluator needs.
37	type Request struct {
38		ID     string
39		User   string
40		Roles  []string
41		Reason string
42	}
43
44	// Evaluator calls Claude to assign a risk score to a Teleport access request.
45	type Evaluator struct {
46		client anthropic.Client
47		model  string
48	}
49
50	func New(apiKey, model string) *Evaluator {
51		return &Evaluator{
52			client: anthropic.NewClient(option.WithAPIKey(apiKey)),
53			model:  model,
54		}
55	}
56
57	func (e *Evaluator) Evaluate(ctx context.Context, req Request) (*Assessment, error) {
58		tickets := jiraPattern.FindAllString(req.Reason, -1)
59		prompt := buildPrompt(req.User, req.Roles, req.Reason, tickets)
60
61		msg, err := e.client.Messages.New(ctx, anthropic.MessageNewParams{
62			Model:     e.model,
63			MaxTokens: 512,
64			System: []anthropic.TextBlockParam{
65				{Text: "You are a security risk evaluator for Teleport access requests. Respond only with valid JSON."},
66			},
67			Messages: []anthropic.MessageParam{
68				anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
69			},
70		})
71		if err != nil {
72			return nil, fmt.Errorf("claude API: %w", err)
73		}
74
75		if len(msg.Content) == 0 {
76			return nil, fmt.Errorf("empty response from claude")
77		}
78
79		text := strings.TrimSpace(msg.Content[0].Text)
80		// Strip markdown code fences that Claude sometimes adds.
81		if strings.HasPrefix(text, "```") {
82			if i := strings.Index(text[3:], "\n"); i >= 0 {
83				text = strings.TrimSuffix(strings.TrimSpace(text[3+i+1:]), "```")
84			}
85		}
86
87		var a Assessment
88		if err := json.Unmarshal([]byte(text), &a); err != nil {
89			return nil, fmt.Errorf("parsing response: %w (raw: %s)", err, text)
90		}
91
92		return &a, nil
93	}
94
95	func buildPrompt(user string, roles []string, reason string, tickets []string) string {
96		ticketInfo := "none"
97		if len(tickets) > 0 {
98			ticketInfo = strings.Join(tickets, ", ")
99		}
100
101		return fmt.Sprintf(`You are a security risk evaluator for Teleport access requests. Respond ONLY with a valid JSON object — no explanation, no markdown.
102
103	Access request details:
104	  User:            %s
105	  Requested roles: %s
106	  Reason provided: %q
107	  JIRA tickets:    %s
108
109	Risk scoring rules:
110	  1–3  (low)    — valid JIRA ticket (CUSTX-NNN or ITSM-NNN) AND a clear description with business justification
111	  4–6  (medium) — either a valid JIRA ticket OR a clear description, but not both
112	  7–10 (high)   — no JIRA ticket AND no/vague description (single words or generic phrases like "need access" score 9–10)
113
114	Return exactly this JSON shape:
115	{
116	  "jira_ticket":          "<ticket ID or null>",
117	  "has_jira_ticket":      <true|false>,
118	  "description_quality":  "<none|poor|fair|good|excellent>",
119	  "risk_score":           <integer 1–10>,
120	  "risk_level":           "<low|medium|high>",
121	  "reasoning":            "<one concise sentence>"
122	}`,
123			user,
124			strings.Join(roles, ", "),
125			reason,
126			ticketInfo,
127		)
128	}
129

package evaluator

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

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/option"
)

// jiraPattern matches CUSTX-NNNN and ITSM-NNNN ticket references (case-insensitive).
var jiraPattern = regexp.MustCompile(`(?i)\b(CUSTX|ITSM)-\d+\b`)

// RiskLevel represents the severity of an access request.
type RiskLevel string

const (
	RiskLow    RiskLevel = "low"
	RiskMedium RiskLevel = "medium"
	RiskHigh   RiskLevel = "high"
)

// Assessment is the structured output of a Claude risk evaluation.
type Assessment struct {
	JIRATicket         string    `json:"jira_ticket"`
	HasJIRATicket      bool      `json:"has_jira_ticket"`
	DescriptionQuality string    `json:"description_quality"`
	RiskScore          int       `json:"risk_score"`
	RiskLevel          RiskLevel `json:"risk_level"`
	Reasoning          string    `json:"reasoning"`
}

// Request holds the access request fields that the evaluator needs.
type Request struct {
	ID     string
	User   string
	Roles  []string
	Reason string
}

// Evaluator calls Claude to assign a risk score to a Teleport access request.
type Evaluator struct {
	client anthropic.Client
	model  string
}

func New(apiKey, model string) *Evaluator {
	return &Evaluator{
		client: anthropic.NewClient(option.WithAPIKey(apiKey)),
		model:  model,
	}
}

func (e *Evaluator) Evaluate(ctx context.Context, req Request) (*Assessment, error) {
	tickets := jiraPattern.FindAllString(req.Reason, -1)
	prompt := buildPrompt(req.User, req.Roles, req.Reason, tickets)

	msg, err := e.client.Messages.New(ctx, anthropic.MessageNewParams{
		Model:     e.model,
		MaxTokens: 512,
		System: []anthropic.TextBlockParam{
			{Text: "You are a security risk evaluator for Teleport access requests. Respond only with valid JSON."},
		},
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("claude API: %w", err)
	}

	if len(msg.Content) == 0 {
		return nil, fmt.Errorf("empty response from claude")
	}

	text := strings.TrimSpace(msg.Content[0].Text)
	// Strip markdown code fences that Claude sometimes adds.
	if strings.HasPrefix(text, "```") {
		if i := strings.Index(text[3:], "\n"); i >= 0 {
			text = strings.TrimSuffix(strings.TrimSpace(text[3+i+1:]), "```")
		}
	}

	var a Assessment
	if err := json.Unmarshal([]byte(text), &a); err != nil {
		return nil, fmt.Errorf("parsing response: %w (raw: %s)", err, text)
	}

	return &a, nil
}

func buildPrompt(user string, roles []string, reason string, tickets []string) string {
	ticketInfo := "none"
	if len(tickets) > 0 {
		ticketInfo = strings.Join(tickets, ", ")
	}

	return fmt.Sprintf(`You are a security risk evaluator for Teleport access requests. Respond ONLY with a valid JSON object — no explanation, no markdown.

Access request details:
  User:            %s
  Requested roles: %s
  Reason provided: %q
  JIRA tickets:    %s

Risk scoring rules:
  1–3  (low)    — valid JIRA ticket (CUSTX-NNN or ITSM-NNN) AND a clear description with business justification
  4–6  (medium) — either a valid JIRA ticket OR a clear description, but not both
  7–10 (high)   — no JIRA ticket AND no/vague description (single words or generic phrases like "need access" score 9–10)

Return exactly this JSON shape:
{
  "jira_ticket":          "<ticket ID or null>",
  "has_jira_ticket":      <true|false>,
  "description_quality":  "<none|poor|fair|good|excellent>",
  "risk_score":           <integer 1–10>,
  "risk_level":           "<low|medium|high>",
  "reasoning":            "<one concise sentence>"
}`,
		user,
		strings.Join(roles, ", "),
		reason,
		ticketInfo,
	)
}


1	package bedrock
2
3	import (
4		"bytes"
5		"context"
6		"crypto/sha256"
7		"encoding/base64"
8		"encoding/hex"
9		"encoding/json"
10		"fmt"
11		"io"
12		"net/http"
13		"net/url"
14		"os"
15		"time"
16
17		"github.com/aws/aws-sdk-go-v2/aws"
18		"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"
19		"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi"
20		v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
21		"github.com/aws/aws-sdk-go-v2/config"
22		"github.com/aws/smithy-go/auth/bearer"
23		"github.com/tidwall/gjson"
24		"github.com/tidwall/sjson"
25
26		"github.com/anthropics/anthropic-sdk-go/internal/requestconfig"
27		"github.com/anthropics/anthropic-sdk-go/option"
28		"github.com/anthropics/anthropic-sdk-go/packages/ssestream"
29	)
30
31	const DefaultVersion = "bedrock-2023-05-31"
32
33	var DefaultEndpoints = map[string]bool{
34		"/v1/complete": true,
35		"/v1/messages": true,
36	}
37
38	func NewStaticBearerTokenProvider(token string) *bearer.StaticTokenProvider {
39		return &bearer.StaticTokenProvider{
40			Token: bearer.Token{
41				Value:     token,
42				CanExpire: false,
43			},
44		}
45	}
46
47	type eventstreamChunk struct {
48		Bytes string `json:"bytes"`
49		P     string `json:"p"`
50	}
51
52	type eventstreamDecoder struct {
53		eventstream.Decoder
54
55		rc  io.ReadCloser
56		evt ssestream.Event
57		err error
58	}
59
60	func (e *eventstreamDecoder) Close() error {
61		return e.rc.Close()
62	}
63
64	func (e *eventstreamDecoder) Err() error {
65		return e.err
66	}
67
68	func (e *eventstreamDecoder) Next() bool {
69		if e.err != nil {
70			return false
71		}
72
73		msg, err := e.Decoder.Decode(e.rc, nil)
74		if err != nil {
75			e.err = err
76			return false
77		}
78
79		messageType := msg.Headers.Get(eventstreamapi.MessageTypeHeader)
80		if messageType == nil {

package bedrock

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"
	"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi"
	v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/smithy-go/auth/bearer"
	"github.com/tidwall/gjson"
	"github.com/tidwall/sjson"

	"github.com/anthropics/anthropic-sdk-go/internal/requestconfig"
	"github.com/anthropics/anthropic-sdk-go/option"
	"github.com/anthropics/anthropic-sdk-go/packages/ssestream"
)

const DefaultVersion = "bedrock-2023-05-31"

var DefaultEndpoints = map[string]bool{
	"/v1/complete": true,
	"/v1/messages": true,
}

func NewStaticBearerTokenProvider(token string) *bearer.StaticTokenProvider {
	return &bearer.StaticTokenProvider{
		Token: bearer.Token{
			Value:     token,
			CanExpire: false,
		},
	}
}

type eventstreamChunk struct {
	Bytes string `json:"bytes"`
	P     string `json:"p"`
}

type eventstreamDecoder struct {
	eventstream.Decoder

	rc  io.ReadCloser
	evt ssestream.Event
	err error
}

func (e *eventstreamDecoder) Close() error {
	return e.rc.Close()
}

func (e *eventstreamDecoder) Err() error {
	return e.err
}

func (e *eventstreamDecoder) Next() bool {
	if e.err != nil {
		return false
	}

	msg, err := e.Decoder.Decode(e.rc, nil)
	if err != nil {
		e.err = err
		return false
	}

	messageType := msg.Headers.Get(eventstreamapi.MessageTypeHeader)
	if messageType == nil {


1	package config
2
3	import (
4		"errors"
5		"os"
6	)
7
8	type Config struct {
9		TeleportAddr         string
10		TeleportIdentityFile string
11		AnthropicAPIKey      string
12		ClaudeModel          string
13	}
14
15	func Load() (*Config, error) {
16		cfg := &Config{
17			TeleportAddr:         os.Getenv("TELEPORT_ADDR"),
18			TeleportIdentityFile: os.Getenv("TELEPORT_IDENTITY_FILE"),
19			AnthropicAPIKey:      os.Getenv("ANTHROPIC_API_KEY"),
20			ClaudeModel:          os.Getenv("CLAUDE_MODEL"),
21		}
22
23		if cfg.ClaudeModel == "" {
24			cfg.ClaudeModel = "claude-sonnet-4-6"
25		}
26
27		if cfg.TeleportAddr == "" {
28			return nil, errors.New("TELEPORT_ADDR is required")
29		}
30		if cfg.TeleportIdentityFile == "" {
31			return nil, errors.New("TELEPORT_IDENTITY_FILE is required")
32		}
33		if cfg.AnthropicAPIKey == "" {
34			return nil, errors.New("ANTHROPIC_API_KEY is required")
35		}
36
37		return cfg, nil
38	}
39

package config

import (
	"errors"
	"os"
)

type Config struct {
	TeleportAddr         string
	TeleportIdentityFile string
	AnthropicAPIKey      string
	ClaudeModel          string
}

func Load() (*Config, error) {
	cfg := &Config{
		TeleportAddr:         os.Getenv("TELEPORT_ADDR"),
		TeleportIdentityFile: os.Getenv("TELEPORT_IDENTITY_FILE"),
		AnthropicAPIKey:      os.Getenv("ANTHROPIC_API_KEY"),
		ClaudeModel:          os.Getenv("CLAUDE_MODEL"),
	}

	if cfg.ClaudeModel == "" {
		cfg.ClaudeModel = "claude-sonnet-4-6"
	}

	if cfg.TeleportAddr == "" {
		return nil, errors.New("TELEPORT_ADDR is required")
	}
	if cfg.TeleportIdentityFile == "" {
		return nil, errors.New("TELEPORT_IDENTITY_FILE is required")
	}
	if cfg.AnthropicAPIKey == "" {
		return nil, errors.New("ANTHROPIC_API_KEY is required")
	}

	return cfg, nil
}


grep -n 'func NewClient\|WithRegion\|WithAWSConfig\|WithLoadDefaultConfig\|option\.' /Users/steve.briggs/go/pkg/mod/github.com/anthropics/anthropic-sdk-go@v1.37.0/bedrock/bedrock.go 2>/dev/null | head -30

Find Bedrock client constructor and options


177:// WithLoadDefaultConfig returns a request option which loads the default config for Amazon and registers
181:func WithLoadDefaultConfig(ctx context.Context, optFns ...func(*config.LoadOptions) error) option.RequestOption {
195:func WithConfig(cfg aws.Config) option.RequestOption {
214:			option.WithBaseURL(fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com", cfg.Region)),
215:			option.WithMiddleware(middleware),
220:func bedrockMiddleware(signer *v4.Signer, cfg aws.Config) option.Middleware {
221:	return func(r *http.Request, next option.MiddlewareNext) (res *http.Response, err error) {


The SDK uses the same `anthropic.Client` type for both backends — Bedrock is just a middleware option. The only surface change needed is: config picks the provider, main.go builds the right client, evaluator accepts it from the outside. Let me make those three changes.


175	}
176
177	// WithLoadDefaultConfig returns a request option which loads the default config for Amazon and registers
178	// middleware that intercepts request to the Messages API so that this SDK can be used with Amazon Bedrock.
179	//
180	// If you already have an [aws.Config], it is recommended that you instead call [WithConfig] directly.
181	func WithLoadDefaultConfig(ctx context.Context, optFns ...func(*config.LoadOptions) error) option.RequestOption {
182		cfg, err := config.LoadDefaultConfig(ctx, optFns...)
183		if err != nil {
184			panic(err)
185		}
186		return WithConfig(cfg)
187	}
188
189	// WithConfig returns a request option that uses the provided config and registers middleware to
190	// intercept requests to the Messages API, enabling this SDK to work with Amazon Bedrock.
191	//
192	// Authentication is determined as follows: if the AWS_BEARER_TOKEN_BEDROCK environment variable is
193	// set, it is used for bearer token authentication. Otherwise, if cfg.BearerAuthTokenProvider is set,
194	// it is used. If neither is available, cfg.Credentials is used for AWS SigV4 signing and must be set.
195	func WithConfig(cfg aws.Config) option.RequestOption {
196		var credentialErr error
197
198		if cfg.BearerAuthTokenProvider == nil {
199			if token := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); token != "" {
200				cfg.BearerAuthTokenProvider = NewStaticBearerTokenProvider(token)
201			}
202		} else if cfg.BearerAuthTokenProvider == nil && cfg.Credentials == nil {
203			credentialErr = fmt.Errorf("expected AWS credentials to be set")
204		}
205
206		signer := v4.NewSigner()
207		middleware := bedrockMiddleware(signer, cfg)
208
209		return requestconfig.RequestOptionFunc(func(rc *requestconfig.RequestConfig) error {
210			if credentialErr != nil {
211				return credentialErr
212			}
213			return rc.Apply(
214				option.WithBaseURL(fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com", cfg.Region)),
215				option.WithMiddleware(middleware),
216			)
217		})
218	}
219
220	func bedrockMiddleware(signer *v4.Signer, cfg aws.Config) option.Middleware {
221		return func(r *http.Request, next option.MiddlewareNext) (res *http.Response, err error) {
222			var body []byte
223			if r.Body != nil {
224				body, err = io.ReadAll(r.Body)

}

// WithLoadDefaultConfig returns a request option which loads the default config for Amazon and registers
// middleware that intercepts request to the Messages API so that this SDK can be used with Amazon Bedrock.
//
// If you already have an [aws.Config], it is recommended that you instead call [WithConfig] directly.
func WithLoadDefaultConfig(ctx context.Context, optFns ...func(*config.LoadOptions) error) option.RequestOption {
	cfg, err := config.LoadDefaultConfig(ctx, optFns...)
	if err != nil {
		panic(err)
	}
	return WithConfig(cfg)
}

// WithConfig returns a request option that uses the provided config and registers middleware to
// intercept requests to the Messages API, enabling this SDK to work with Amazon Bedrock.
//
// Authentication is determined as follows: if the AWS_BEARER_TOKEN_BEDROCK environment variable is
// set, it is used for bearer token authentication. Otherwise, if cfg.BearerAuthTokenProvider is set,
// it is used. If neither is available, cfg.Credentials is used for AWS SigV4 signing and must be set.
func WithConfig(cfg aws.Config) option.RequestOption {
	var credentialErr error

	if cfg.BearerAuthTokenProvider == nil {
		if token := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); token != "" {
			cfg.BearerAuthTokenProvider = NewStaticBearerTokenProvider(token)
		}
	} else if cfg.BearerAuthTokenProvider == nil && cfg.Credentials == nil {
		credentialErr = fmt.Errorf("expected AWS credentials to be set")
	}

	signer := v4.NewSigner()
	middleware := bedrockMiddleware(signer, cfg)

	return requestconfig.RequestOptionFunc(func(rc *requestconfig.RequestConfig) error {
		if credentialErr != nil {
			return credentialErr
		}
		return rc.Apply(
			option.WithBaseURL(fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com", cfg.Region)),
			option.WithMiddleware(middleware),
		)
	})
}

func bedrockMiddleware(signer *v4.Signer, cfg aws.Config) option.Middleware {
	return func(r *http.Request, next option.MiddlewareNext) (res *http.Response, err error) {
		var body []byte
		if r.Body != nil {
			body, err = io.ReadAll(r.Body)


The file /Users/steve.briggs/projects/operations/services/televibe/internal/config/config.go has been updated successfully.

package config

import (
	"errors"
	"os"
)

type Config struct {
	TeleportAddr         string
	TeleportIdentityFile string
	AnthropicAPIKey      string
	ClaudeModel          string
}

func Load() (*Config, error) {
	cfg := &Config{
		TeleportAddr:         os.Getenv("TELEPORT_ADDR"),
		TeleportIdentityFile: os.Getenv("TELEPORT_IDENTITY_FILE"),
		AnthropicAPIKey:      os.Getenv("ANTHROPIC_API_KEY"),
		ClaudeModel:          os.Getenv("CLAUDE_MODEL"),
	}

	if cfg.ClaudeModel == "" {
		cfg.ClaudeModel = "claude-sonnet-4-6"
	}

	if cfg.TeleportAddr == "" {
		return nil, errors.New("TELEPORT_ADDR is required")
	}
	if cfg.TeleportIdentityFile == "" {
		return nil, errors.New("TELEPORT_IDENTITY_FILE is required")
	}
	if cfg.AnthropicAPIKey == "" {
		return nil, errors.New("ANTHROPIC_API_KEY is required")
	}

	return cfg, nil
}

package config

import (
	"errors"
	"os"
)

const (
	ProviderAnthropic = "anthropic"
	ProviderBedrock   = "bedrock"

	defaultModelAnthropic = "claude-sonnet-4-6"
	// Bedrock model IDs use cross-region inference profile format.
	// Override with CLAUDE_MODEL if you need a different version.
	defaultModelBedrock = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"
)

type Config struct {
	TeleportAddr         string
	TeleportIdentityFile string
	LLMProvider          string // "anthropic" (default) or "bedrock"
	AnthropicAPIKey      string // required when LLMProvider == "anthropic"
	BedrockRegion        string // optional; falls back to AWS_DEFAULT_REGION / profile
	ClaudeModel          string
}

func Load() (*Config, error) {
	cfg := &Config{
		TeleportAddr:         os.Getenv("TELEPORT_ADDR"),
		TeleportIdentityFile: os.Getenv("TELEPORT_IDENTITY_FILE"),
		LLMProvider:          os.Getenv("LLM_PROVIDER"),
		AnthropicAPIKey:      os.Getenv("ANTHROPIC_API_KEY"),
		BedrockRegion:        os.Getenv("BEDROCK_REGION"),
		ClaudeModel:          os.Getenv("CLAUDE_MODEL"),
	}

	if cfg.LLMProvider == "" {
		cfg.LLMProvider = ProviderAnthropic
	}

	if cfg.ClaudeModel == "" {
		switch cfg.LLMProvider {
		case ProviderBedrock:
			cfg.ClaudeModel = defaultModelBedrock
		default:
			cfg.ClaudeModel = defaultModelAnthropic
		}
	}

	if cfg.TeleportAddr == "" {
		return nil, errors.New("TELEPORT_ADDR is required")
	}
	if cfg.TeleportIdentityFile == "" {
		return nil, errors.New("TELEPORT_IDENTITY_FILE is required")
	}

	switch cfg.LLMProvider {
	case ProviderAnthropic:
		if cfg.AnthropicAPIKey == "" {
			return nil, errors.New("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic")
		}
	case ProviderBedrock:
		// credentials come from the standard AWS credential chain
	default:
		return nil, errors.New("LLM_PROVIDER must be 'anthropic' or 'bedrock'")
	}

	return cfg, nil
}

+  defaultModelAnthropic = "claude-sonnet-4-6"

+  // Bedrock model IDs use cross-region inference profile format.

+  // Override with CLAUDE_MODEL if you need a different version.

+  defaultModelBedrock = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"

+  LLMProvider          string // "anthropic" (default) or "bedrock"

+  AnthropicAPIKey      string // required when LLMProvider == "anthropic"

+  BedrockRegion        string // optional; falls back to AWS_DEFAULT_REGION / profile

+    LLMProvider:          os.Getenv("LLM_PROVIDER"),

+    BedrockRegion:        os.Getenv("BEDROCK_REGION"),

ClaudeModel:          os.Getenv("CLAUDE_MODEL"),

+    cfg.LLMProvider = ProviderAnthropic

-    cfg.ClaudeModel = "claude-sonnet-4-6"

+      cfg.ClaudeModel = defaultModelBedrock

+      cfg.ClaudeModel = defaultModelAnthropic

-    return nil, errors.New("ANTHROPIC_API_KEY is required")

+      return nil, errors.New("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic")

+    // credentials come from the standard AWS credential chain

+    return nil, errors.New("LLM_PROVIDER must be 'anthropic' or 'bedrock'")


Good. `bedrock.WithLoadDefaultConfig(ctx)` passes right into `anthropic.NewClient()` — same type, no separate client needed. Plan:

1. **config.go** — add `LLMProvider` + `BedrockRegion`; make `ANTHROPIC_API_KEY` conditional
2. **evaluator.go** — inject `anthropic.Client` instead of building it inside
3. **main.go** — build the right client and hand it to the evaluator
4. Update tests


Edit

package config

import (
	"errors"
	"os"
)

type Config struct {
	TeleportAddr         string
	TeleportIdentityFile string
	AnthropicAPIKey      string
	ClaudeModel          string
}

func Load() (*Config, error) {
	cfg := &Config{
		TeleportAddr:         os.Getenv("TELEPORT_ADDR"),
		TeleportIdentityFile: os.Getenv("TELEPORT_IDENTITY_FILE"),
		AnthropicAPIKey:      os.Getenv("ANTHROPIC_API_KEY"),
		ClaudeModel:          os.Getenv("CLAUDE_MODEL"),
	}

	if cfg.ClaudeModel == "" {
		cfg.ClaudeModel = "claude-sonnet-4-6"
	}

	if cfg.TeleportAddr == "" {
		return nil, errors.New("TELEPORT_ADDR is required")
	}
	if cfg.TeleportIdentityFile == "" {
		return nil, errors.New("TELEPORT_IDENTITY_FILE is required")
	}
	if cfg.AnthropicAPIKey == "" {
		return nil, errors.New("ANTHROPIC_API_KEY is required")
	}

	return cfg, nil
}

package config

import (
	"errors"
	"os"
)

const (
	ProviderAnthropic = "anthropic"
	ProviderBedrock   = "bedrock"

	defaultModelAnthropic = "claude-sonnet-4-6"
	// Bedrock model IDs use cross-region inference profile format.
	// Override with CLAUDE_MODEL if you need a different version.
	defaultModelBedrock = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"
)

type Config struct {
	TeleportAddr         string
	TeleportIdentityFile string
	LLMProvider          string // "anthropic" (default) or "bedrock"
	AnthropicAPIKey      string // required when LLMProvider == "anthropic"
	BedrockRegion        string // optional; falls back to AWS_DEFAULT_REGION / profile
	ClaudeModel          string
}

func Load() (*Config, error) {
	cfg := &Config{
		TeleportAddr:         os.Getenv("TELEPORT_ADDR"),
		TeleportIdentityFile: os.Getenv("TELEPORT_IDENTITY_FILE"),
		LLMProvider:          os.Getenv("LLM_PROVIDER"),
		AnthropicAPIKey:      os.Getenv("ANTHROPIC_API_KEY"),
		BedrockRegion:        os.Getenv("BEDROCK_REGION"),
		ClaudeModel:          os.Getenv("CLAUDE_MODEL"),
	}

	if cfg.LLMProvider == "" {
		cfg.LLMProvider = ProviderAnthropic
	}

	if cfg.ClaudeModel == "" {
		switch cfg.LLMProvider {
		case ProviderBedrock:
			cfg.ClaudeModel = defaultModelBedrock
		default:
			cfg.ClaudeModel = defaultModelAnthropic
		}
	}

	if cfg.TeleportAddr == "" {
		return nil, errors.New("TELEPORT_ADDR is required")
	}
	if cfg.TeleportIdentityFile == "" {
		return nil, errors.New("TELEPORT_IDENTITY_FILE is required")
	}

	switch cfg.LLMProvider {
	case ProviderAnthropic:
		if cfg.AnthropicAPIKey == "" {
			return nil, errors.New("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic")
		}
	case ProviderBedrock:
		// credentials come from the standard AWS credential chain
	default:
		return nil, errors.New("LLM_PROVIDER must be 'anthropic' or 'bedrock'")
	}

	return cfg, nil
}


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

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/option"
)

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

	"github.com/anthropics/anthropic-sdk-go"
)


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

	"github.com/anthropics/anthropic-sdk-go"
)

package evaluator

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

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/option"
)

// jiraPattern matches CUSTX-NNNN and ITSM-NNNN ticket references (case-insensitive).
var jiraPattern = regexp.MustCompile(`(?i)\b(CUSTX|ITSM)-\d+\b`)

// RiskLevel represents the severity of an access request.
type RiskLevel string

const (
	RiskLow    RiskLevel = "low"
	RiskMedium RiskLevel = "medium"
	RiskHigh   RiskLevel = "high"
)

// Assessment is the structured output of a Claude risk evaluation.
type Assessment struct {
	JIRATicket         string    `json:"jira_ticket"`
	HasJIRATicket      bool      `json:"has_jira_ticket"`
	DescriptionQuality string    `json:"description_quality"`
	RiskScore          int       `json:"risk_score"`
	RiskLevel          RiskLevel `json:"risk_level"`
	Reasoning          string    `json:"reasoning"`
}

// Request holds the access request fields that the evaluator needs.
type Request struct {
	ID     string
	User   string
	Roles  []string
	Reason string
}

// Evaluator calls Claude to assign a risk score to a Teleport access request.
type Evaluator struct {
	client anthropic.Client
	model  string
}

func New(apiKey, model string) *Evaluator {
	return &Evaluator{
		client: anthropic.NewClient(option.WithAPIKey(apiKey)),
		model:  model,
	}
}

func (e *Evaluator) Evaluate(ctx context.Context, req Request) (*Assessment, error) {
	tickets := jiraPattern.FindAllString(req.Reason, -1)
	prompt := buildPrompt(req.User, req.Roles, req.Reason, tickets)

	msg, err := e.client.Messages.New(ctx, anthropic.MessageNewParams{
		Model:     e.model,
		MaxTokens: 512,
		System: []anthropic.TextBlockParam{
			{Text: "You are a security risk evaluator for Teleport access requests. Respond only with valid JSON."},
		},
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("claude API: %w", err)
	}

	if len(msg.Content) == 0 {
		return nil, fmt.Errorf("empty response from claude")
	}

	text := strings.TrimSpace(msg.Content[0].Text)
	// Strip markdown code fences that Claude sometimes adds.
	if strings.HasPrefix(text, "```") {
		if i := strings.Index(text[3:], "\n"); i >= 0 {
			text = strings.TrimSuffix(strings.TrimSpace(text[3+i+1:]), "```")
		}
	}

	var a Assessment
	if err := json.Unmarshal([]byte(text), &a); err != nil {
		return nil, fmt.Errorf("parsing response: %w (raw: %s)", err, text)
	}

	return &a, nil
}

func buildPrompt(user string, roles []string, reason string, tickets []string) string {
	ticketInfo := "none"
	if len(tickets) > 0 {
		ticketInfo = strings.Join(tickets, ", ")
	}

	return fmt.Sprintf(`You are a security risk evaluator for Teleport access requests. Respond ONLY with a valid JSON object — no explanation, no markdown.

Access request details:
  User:            %s
  Requested roles: %s
  Reason provided: %q
  JIRA tickets:    %s

Risk scoring rules:
  1–3  (low)    — valid JIRA ticket (CUSTX-NNN or ITSM-NNN) AND a clear description with business justification
  4–6  (medium) — either a valid JIRA ticket OR a clear description, but not both
  7–10 (high)   — no JIRA ticket AND no/vague description (single words or generic phrases like "need access" score 9–10)

Return exactly this JSON shape:
{
  "jira_ticket":          "<ticket ID or null>",
  "has_jira_ticket":      <true|false>,
  "description_quality":  "<none|poor|fair|good|excellent>",
  "risk_score":           <integer 1–10>,
  "risk_level":           "<low|medium|high>",
  "reasoning":            "<one concise sentence>"
}`,
		user,
		strings.Join(roles, ", "),
		reason,
		ticketInfo,
	)
}


func New(apiKey, model string) *Evaluator {
	return &Evaluator{
		client: anthropic.NewClient(option.WithAPIKey(apiKey)),
		model:  model,
	}
}

func New(client anthropic.Client, model string) *Evaluator {
	return &Evaluator{client: client, model: model}
}


func New(client anthropic.Client, model string) *Evaluator {
	return &Evaluator{client: client, model: model}
}

package evaluator

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

	"github.com/anthropics/anthropic-sdk-go"
)

// jiraPattern matches CUSTX-NNNN and ITSM-NNNN ticket references (case-insensitive).
var jiraPattern = regexp.MustCompile(`(?i)\b(CUSTX|ITSM)-\d+\b`)

// RiskLevel represents the severity of an access request.
type RiskLevel string

const (
	RiskLow    RiskLevel = "low"
	RiskMedium RiskLevel = "medium"
	RiskHigh   RiskLevel = "high"
)

// Assessment is the structured output of a Claude risk evaluation.
type Assessment struct {
	JIRATicket         string    `json:"jira_ticket"`
	HasJIRATicket      bool      `json:"has_jira_ticket"`
	DescriptionQuality string    `json:"description_quality"`
	RiskScore          int       `json:"risk_score"`
	RiskLevel          RiskLevel `json:"risk_level"`
	Reasoning          string    `json:"reasoning"`
}

// Request holds the access request fields that the evaluator needs.
type Request struct {
	ID     string
	User   string
	Roles  []string
	Reason string
}

// Evaluator calls Claude to assign a risk score to a Teleport access request.
type Evaluator struct {
	client anthropic.Client
	model  string
}

func New(apiKey, model string) *Evaluator {
	return &Evaluator{
		client: anthropic.NewClient(option.WithAPIKey(apiKey)),
		model:  model,
	}
}

func (e *Evaluator) Evaluate(ctx context.Context, req Request) (*Assessment, error) {
	tickets := jiraPattern.FindAllString(req.Reason, -1)
	prompt := buildPrompt(req.User, req.Roles, req.Reason, tickets)

	msg, err := e.client.Messages.New(ctx, anthropic.MessageNewParams{
		Model:     e.model,
		MaxTokens: 512,
		System: []anthropic.TextBlockParam{
			{Text: "You are a security risk evaluator for Teleport access requests. Respond only with valid JSON."},
		},
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("claude API: %w", err)
	}

	if len(msg.Content) == 0 {
		return nil, fmt.Errorf("empty response from claude")
	}

	text := strings.TrimSpace(msg.Content[0].Text)
	// Strip markdown code fences that Claude sometimes adds.
	if strings.HasPrefix(text, "```") {
		if i := strings.Index(text[3:], "\n"); i >= 0 {
			text = strings.TrimSuffix(strings.TrimSpace(text[3+i+1:]), "```")
		}
	}

	var a Assessment
	if err := json.Unmarshal([]byte(text), &a); err != nil {
		return nil, fmt.Errorf("parsing response: %w (raw: %s)", err, text)
	}

	return &a, nil
}

func buildPrompt(user string, roles []string, reason string, tickets []string) string {
	ticketInfo := "none"
	if len(tickets) > 0 {
		ticketInfo = strings.Join(tickets, ", ")
	}

	return fmt.Sprintf(`You are a security risk evaluator for Teleport access requests. Respond ONLY with a valid JSON object — no explanation, no markdown.

Access request details:
  User:            %s
  Requested roles: %s
  Reason provided: %q
  JIRA tickets:    %s

Risk scoring rules:
  1–3  (low)    — valid JIRA ticket (CUSTX-NNN or ITSM-NNN) AND a clear description with business justification
  4–6  (medium) — either a valid JIRA ticket OR a clear description, but not both
  7–10 (high)   — no JIRA ticket AND no/vague description (single words or generic phrases like "need access" score 9–10)

Return exactly this JSON shape:
{
  "jira_ticket":          "<ticket ID or null>",
  "has_jira_ticket":      <true|false>,
  "description_quality":  "<none|poor|fair|good|excellent>",
  "risk_score":           <integer 1–10>,
  "risk_level":           "<low|medium|high>",
  "reasoning":            "<one concise sentence>"
}`,
		user,
		strings.Join(roles, ", "),
		reason,
		ticketInfo,
	)
}

-func New(apiKey, model string) *Evaluator {

-    client: anthropic.NewClient(option.WithAPIKey(apiKey)),

+func New(client anthropic.Client, model string) *Evaluator {

+  return &Evaluator{client: client, model: model}


Now update main.go to build the right client:


package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/watcher"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	eval := evaluator.New(cfg.AnthropicAPIKey, cfg.ClaudeModel)

package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/watcher"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	var anthropicClient anthropic.Client
	switch cfg.LLMProvider {
	case config.ProviderBedrock:
		opts := []func(*awsconfig.LoadOptions) error{}
		if cfg.BedrockRegion != "" {
			opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
		}
		anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
		logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
	default:
		anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
		logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
	}

	eval := evaluator.New(anthropicClient, cfg.ClaudeModel)


The file /Users/steve.briggs/projects/operations/services/televibe/main.go has been updated successfully.

package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/watcher"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	var anthropicClient anthropic.Client
	switch cfg.LLMProvider {
	case config.ProviderBedrock:
		opts := []func(*awsconfig.LoadOptions) error{}
		if cfg.BedrockRegion != "" {
			opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
		}
		anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
		logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
	default:
		anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
		logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
	}

	eval := evaluator.New(anthropicClient, cfg.ClaudeModel)

package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/watcher"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	eval := evaluator.New(cfg.AnthropicAPIKey, cfg.ClaudeModel)

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	w, err := watcher.New(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile, logger)
	if err != nil {
		logger.Error("failed to connect to teleport", "error", err)
		os.Exit(1)
	}
	defer w.Close()

	logger.Info("televibe started", "teleport_addr", cfg.TeleportAddr, "model", cfg.ClaudeModel)

	for {
		select {
		case req, ok := <-w.Requests():
			if !ok {
				return
			}
			go evaluate(ctx, eval, req, logger)
		case <-ctx.Done():
			return
		}
	}
}

func evaluate(ctx context.Context, eval *evaluator.Evaluator, req evaluator.Request, logger *slog.Logger) {
	a, err := eval.Evaluate(ctx, req)
	if err != nil {
		logger.Error("evaluation failed", "request_id", req.ID, "user", req.User, "error", err)
		return
	}

	logger.Info("access request evaluated",
		"request_id", req.ID,
		"user", req.User,
		"roles", req.Roles,
		"risk_score", a.RiskScore,
		"risk_level", a.RiskLevel,
		"has_jira_ticket", a.HasJIRATicket,
		"jira_ticket", a.JIRATicket,
		"description_quality", a.DescriptionQuality,
		"reasoning", a.Reasoning,
	)
}

-  eval := evaluator.New(cfg.AnthropicAPIKey, cfg.ClaudeModel)

+    opts := []func(*awsconfig.LoadOptions) error{}

+      opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))

+    anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))

+    logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)

+    anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))

+    logger.Info("using Anthropic API", "model", cfg.ClaudeModel)

+  eval := evaluator.New(anthropicClient, cfg.ClaudeModel)


I need to fix the import alias for `aws-sdk-go-v2/config`:


1	package main
2
3	import (
4		"context"
5		"log/slog"
6		"os"
7		"os/signal"
8		"syscall"
9
10		"github.com/anthropics/anthropic-sdk-go"
11		"github.com/anthropics/anthropic-sdk-go/bedrock"
12		"github.com/anthropics/anthropic-sdk-go/option"
13		"github.com/immuta/operations/services/televibe/internal/config"
14		"github.com/immuta/operations/services/televibe/internal/evaluator"
15		"github.com/immuta/operations/services/televibe/internal/watcher"
16	)
17
18	func main() {
19		logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
20
21		cfg, err := config.Load()
22		if err != nil {
23			logger.Error("configuration error", "error", err)
24			os.Exit(1)
25		}
26
27		var anthropicClient anthropic.Client
28		switch cfg.LLMProvider {
29		case config.ProviderBedrock:
30			opts := []func(*awsconfig.LoadOptions) error{}
31			if cfg.BedrockRegion != "" {
32				opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
33			}
34			anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
35			logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
36		default:
37			anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
38			logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
39		}
40
41		eval := evaluator.New(anthropicClient, cfg.ClaudeModel)
42
43		ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
44		defer cancel()
45
46		w, err := watcher.New(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile, logger)
47		if err != nil {
48			logger.Error("failed to connect to teleport", "error", err)
49			os.Exit(1)
50		}
51		defer w.Close()
52
53		logger.Info("televibe started", "teleport_addr", cfg.TeleportAddr, "model", cfg.ClaudeModel)
54
55		for {
56			select {
57			case req, ok := <-w.Requests():
58				if !ok {
59					return
60				}
61				go evaluate(ctx, eval, req, logger)
62			case <-ctx.Done():
63				return
64			}
65		}
66	}
67
68	func evaluate(ctx context.Context, eval *evaluator.Evaluator, req evaluator.Request, logger *slog.Logger) {
69		a, err := eval.Evaluate(ctx, req)
70		if err != nil {
71			logger.Error("evaluation failed", "request_id", req.ID, "user", req.User, "error", err)
72			return
73		}
74
75		logger.Info("access request evaluated",
76			"request_id", req.ID,
77			"user", req.User,
78			"roles", req.Roles,
79			"risk_score", a.RiskScore,
80			"risk_level", a.RiskLevel,
81			"has_jira_ticket", a.HasJIRATicket,
82			"jira_ticket", a.JIRATicket,
83			"description_quality", a.DescriptionQuality,
84			"reasoning", a.Reasoning,
85		)
86	}
87

package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/watcher"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	var anthropicClient anthropic.Client
	switch cfg.LLMProvider {
	case config.ProviderBedrock:
		opts := []func(*awsconfig.LoadOptions) error{}
		if cfg.BedrockRegion != "" {
			opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
		}
		anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
		logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
	default:
		anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
		logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
	}

	eval := evaluator.New(anthropicClient, cfg.ClaudeModel)

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	w, err := watcher.New(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile, logger)
	if err != nil {
		logger.Error("failed to connect to teleport", "error", err)
		os.Exit(1)
	}
	defer w.Close()

	logger.Info("televibe started", "teleport_addr", cfg.TeleportAddr, "model", cfg.ClaudeModel)

	for {
		select {
		case req, ok := <-w.Requests():
			if !ok {
				return
			}
			go evaluate(ctx, eval, req, logger)
		case <-ctx.Done():
			return
		}
	}
}

func evaluate(ctx context.Context, eval *evaluator.Evaluator, req evaluator.Request, logger *slog.Logger) {
	a, err := eval.Evaluate(ctx, req)
	if err != nil {
		logger.Error("evaluation failed", "request_id", req.ID, "user", req.User, "error", err)
		return
	}

	logger.Info("access request evaluated",
		"request_id", req.ID,
		"user", req.User,
		"roles", req.Roles,
		"risk_score", a.RiskScore,
		"risk_level", a.RiskLevel,
		"has_jira_ticket", a.HasJIRATicket,
		"jira_ticket", a.JIRATicket,
		"description_quality", a.DescriptionQuality,
		"reasoning", a.Reasoning,
	)
}


	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	"github.com/immuta/operations/services/televibe/internal/config"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/immuta/operations/services/televibe/internal/config"


	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/immuta/operations/services/televibe/internal/config"

package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/watcher"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	var anthropicClient anthropic.Client
	switch cfg.LLMProvider {
	case config.ProviderBedrock:
		opts := []func(*awsconfig.LoadOptions) error{}
		if cfg.BedrockRegion != "" {
			opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
		}
		anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
		logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
	default:
		anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
		logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
	}

	eval := evaluator.New(anthropicClient, cfg.ClaudeModel)

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	w, err := watcher.New(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile, logger)
	if err != nil {
		logger.Error("failed to connect to teleport", "error", err)
		os.Exit(1)
	}
	defer w.Close()

	logger.Info("televibe started", "teleport_addr", cfg.TeleportAddr, "model", cfg.ClaudeModel)

	for {
		select {
		case req, ok := <-w.Requests():
			if !ok {
				return
			}
			go evaluate(ctx, eval, req, logger)
		case <-ctx.Done():
			return
		}
	}
}

func evaluate(ctx context.Context, eval *evaluator.Evaluator, req evaluator.Request, logger *slog.Logger) {
	a, err := eval.Evaluate(ctx, req)
	if err != nil {
		logger.Error("evaluation failed", "request_id", req.ID, "user", req.User, "error", err)
		return
	}

	logger.Info("access request evaluated",
		"request_id", req.ID,
		"user", req.User,
		"roles", req.Roles,
		"risk_score", a.RiskScore,
		"risk_level", a.RiskLevel,
		"has_jira_ticket", a.HasJIRATicket,
		"jira_ticket", a.JIRATicket,
		"description_quality", a.DescriptionQuality,
		"reasoning", a.Reasoning,
	)
}

+  awsconfig "github.com/aws/aws-sdk-go-v2/config"


Bash

GOPROXY='https://proxy.golang.org,athens.infrastructure.immuta.io,direct' GONOSUMCHECK='github.com/immuta/*' go mod tidy 2>&1

Update go.mod with aws-sdk-go-v2/config as direct dep


go: downloading github.com/aws/aws-sdk-go-v2/config v1.27.27
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3
go: downloading github.com/aws/aws-sdk-go-v2/credentials v1.17.27
go: downloading github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11
go: downloading github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sso v1.22.4
go: downloading github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sts v1.30.3
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3


Build after adding Bedrock support


(Bash completed with no output)


Now update the config tests to cover the new provider cases:


Edit

package config

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestLoad(t *testing.T) {
	allKeys := []string{"TELEPORT_ADDR", "TELEPORT_IDENTITY_FILE", "ANTHROPIC_API_KEY", "CLAUDE_MODEL"}

	tests := []struct {
		name          string
		env           map[string]string
		wantErr       string
		wantModel     string
	}{
		{
			name:    "missing TELEPORT_ADDR",
			env:     map[string]string{"TELEPORT_IDENTITY_FILE": "/id.pem", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_ADDR is required",
		},
		{
			name:    "missing TELEPORT_IDENTITY_FILE",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_IDENTITY_FILE is required",
		},
		{
			name:    "missing ANTHROPIC_API_KEY",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem"},
			wantErr: "ANTHROPIC_API_KEY is required",
		},
		{
			name: "defaults",
			env: map[string]string{
				"TELEPORT_ADDR":          "tp.example.com:443",
				"TELEPORT_IDENTITY_FILE": "/id.pem",
				"ANTHROPIC_API_KEY":      "sk-ant-key",
			},
			wantModel: "claude-sonnet-4-6",
		},
		{
			name: "custom model",
			env: map[string]string{
				"TELEPORT_ADDR":          "tp.example.com:443",
				"TELEPORT_IDENTITY_FILE": "/id.pem",
				"ANTHROPIC_API_KEY":      "sk-ant-key",
				"CLAUDE_MODEL":           "claude-opus-4-7",
			},
			wantModel: "claude-opus-4-7",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			for _, key := range allKeys {
				t.Setenv(key, "")
			}
			for k, v := range tt.env {
				t.Setenv(k, v)
			}

			cfg, err := Load()

			if tt.wantErr != "" {
				require.Error(t, err)
				assert.Contains(t, err.Error(), tt.wantErr)
				return
			}

			require.NoError(t, err)
			assert.Equal(t, tt.wantModel, cfg.ClaudeModel)
		})
	}
}

package config

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestLoad(t *testing.T) {
	allKeys := []string{
		"TELEPORT_ADDR", "TELEPORT_IDENTITY_FILE",
		"LLM_PROVIDER", "ANTHROPIC_API_KEY", "BEDROCK_REGION", "CLAUDE_MODEL",
	}

	base := map[string]string{
		"TELEPORT_ADDR":          "tp.example.com:443",
		"TELEPORT_IDENTITY_FILE": "/id.pem",
		"ANTHROPIC_API_KEY":      "sk-ant-key",
	}

	tests := []struct {
		name             string
		env              map[string]string
		wantErr          string
		wantProvider     string
		wantModel        string
		wantBedrockRegion string
	}{
		{
			name:    "missing TELEPORT_ADDR",
			env:     map[string]string{"TELEPORT_IDENTITY_FILE": "/id.pem", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_ADDR is required",
		},
		{
			name:    "missing TELEPORT_IDENTITY_FILE",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_IDENTITY_FILE is required",
		},
		{
			name:    "missing ANTHROPIC_API_KEY with default provider",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem"},
			wantErr: "ANTHROPIC_API_KEY is required",
		},
		{
			name:    "invalid LLM_PROVIDER",
			env:     merge(base, map[string]string{"LLM_PROVIDER": "openai"}),
			wantErr: "LLM_PROVIDER must be",
		},
		{
			name:         "defaults (anthropic)",
			env:          base,
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
		},
		{
			name:         "explicit anthropic provider",
			env:          merge(base, map[string]string{"LLM_PROVIDER": "anthropic"}),
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
		},
		{
			name:         "bedrock provider, no API key required",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock"},
			wantProvider: ProviderBedrock,
			wantModel:    defaultModelBedrock,
		},
		{
			name:              "bedrock provider with region",
			env:               map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "BEDROCK_REGION": "eu-west-1"},
			wantProvider:      ProviderBedrock,
			wantModel:         defaultModelBedrock,
			wantBedrockRegion: "eu-west-1",
		},
		{
			name:         "custom model overrides provider default",
			env:          merge(base, map[string]string{"CLAUDE_MODEL": "claude-opus-4-7"}),
			wantProvider: ProviderAnthropic,
			wantModel:    "claude-opus-4-7",
		},
		{
			name:         "bedrock with custom model",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "CLAUDE_MODEL": "us.anthropic.claude-3-7-sonnet-20250219-v1:0"},
			wantProvider: ProviderBedrock,
			wantModel:    "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			for _, key := range allKeys {
				t.Setenv(key, "")
			}
			for k, v := range tt.env {
				t.Setenv(k, v)
			}

			cfg, err := Load()

			if tt.wantErr != "" {
				require.Error(t, err)
				assert.Contains(t, err.Error(), tt.wantErr)
				return
			}

			require.NoError(t, err)
			assert.Equal(t, tt.wantProvider, cfg.LLMProvider)
			assert.Equal(t, tt.wantModel, cfg.ClaudeModel)
			assert.Equal(t, tt.wantBedrockRegion, cfg.BedrockRegion)
		})
	}
}

func merge(base, overrides map[string]string) map[string]string {
	out := make(map[string]string, len(base)+len(overrides))
	for k, v := range base {
		out[k] = v
	}
	for k, v := range overrides {
		out[k] = v
	}
	return out
}


The file /Users/steve.briggs/projects/operations/services/televibe/internal/config/config_test.go has been updated successfully.

package config

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestLoad(t *testing.T) {
	allKeys := []string{"TELEPORT_ADDR", "TELEPORT_IDENTITY_FILE", "ANTHROPIC_API_KEY", "CLAUDE_MODEL"}

	tests := []struct {
		name          string
		env           map[string]string
		wantErr       string
		wantModel     string
	}{
		{
			name:    "missing TELEPORT_ADDR",
			env:     map[string]string{"TELEPORT_IDENTITY_FILE": "/id.pem", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_ADDR is required",
		},
		{
			name:    "missing TELEPORT_IDENTITY_FILE",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_IDENTITY_FILE is required",
		},
		{
			name:    "missing ANTHROPIC_API_KEY",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem"},
			wantErr: "ANTHROPIC_API_KEY is required",
		},
		{
			name: "defaults",
			env: map[string]string{
				"TELEPORT_ADDR":          "tp.example.com:443",
				"TELEPORT_IDENTITY_FILE": "/id.pem",
				"ANTHROPIC_API_KEY":      "sk-ant-key",
			},
			wantModel: "claude-sonnet-4-6",
		},
		{
			name: "custom model",
			env: map[string]string{
				"TELEPORT_ADDR":          "tp.example.com:443",
				"TELEPORT_IDENTITY_FILE": "/id.pem",
				"ANTHROPIC_API_KEY":      "sk-ant-key",
				"CLAUDE_MODEL":           "claude-opus-4-7",
			},
			wantModel: "claude-opus-4-7",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			for _, key := range allKeys {
				t.Setenv(key, "")
			}
			for k, v := range tt.env {
				t.Setenv(k, v)
			}

			cfg, err := Load()

			if tt.wantErr != "" {
				require.Error(t, err)
				assert.Contains(t, err.Error(), tt.wantErr)
				return
			}

			require.NoError(t, err)
			assert.Equal(t, tt.wantModel, cfg.ClaudeModel)
		})
	}
}

package config

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestLoad(t *testing.T) {
	allKeys := []string{
		"TELEPORT_ADDR", "TELEPORT_IDENTITY_FILE",
		"LLM_PROVIDER", "ANTHROPIC_API_KEY", "BEDROCK_REGION", "CLAUDE_MODEL",
	}

	base := map[string]string{
		"TELEPORT_ADDR":          "tp.example.com:443",
		"TELEPORT_IDENTITY_FILE": "/id.pem",
		"ANTHROPIC_API_KEY":      "sk-ant-key",
	}

	tests := []struct {
		name             string
		env              map[string]string
		wantErr          string
		wantProvider     string
		wantModel        string
		wantBedrockRegion string
	}{
		{
			name:    "missing TELEPORT_ADDR",
			env:     map[string]string{"TELEPORT_IDENTITY_FILE": "/id.pem", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_ADDR is required",
		},
		{
			name:    "missing TELEPORT_IDENTITY_FILE",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "ANTHROPIC_API_KEY": "key"},
			wantErr: "TELEPORT_IDENTITY_FILE is required",
		},
		{
			name:    "missing ANTHROPIC_API_KEY with default provider",
			env:     map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem"},
			wantErr: "ANTHROPIC_API_KEY is required",
		},
		{
			name:    "invalid LLM_PROVIDER",
			env:     merge(base, map[string]string{"LLM_PROVIDER": "openai"}),
			wantErr: "LLM_PROVIDER must be",
		},
		{
			name:         "defaults (anthropic)",
			env:          base,
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
		},
		{
			name:         "explicit anthropic provider",
			env:          merge(base, map[string]string{"LLM_PROVIDER": "anthropic"}),
			wantProvider: ProviderAnthropic,
			wantModel:    defaultModelAnthropic,
		},
		{
			name:         "bedrock provider, no API key required",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock"},
			wantProvider: ProviderBedrock,
			wantModel:    defaultModelBedrock,
		},
		{
			name:              "bedrock provider with region",
			env:               map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "BEDROCK_REGION": "eu-west-1"},
			wantProvider:      ProviderBedrock,
			wantModel:         defaultModelBedrock,
			wantBedrockRegion: "eu-west-1",
		},
		{
			name:         "custom model overrides provider default",
			env:          merge(base, map[string]string{"CLAUDE_MODEL": "claude-opus-4-7"}),
			wantProvider: ProviderAnthropic,
			wantModel:    "claude-opus-4-7",
		},
		{
			name:         "bedrock with custom model",
			env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "CLAUDE_MODEL": "us.anthropic.claude-3-7-sonnet-20250219-v1:0"},
			wantProvider: ProviderBedrock,
			wantModel:    "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			for _, key := range allKeys {
				t.Setenv(key, "")
			}
			for k, v := range tt.env {
				t.Setenv(k, v)
			}

			cfg, err := Load()

			if tt.wantErr != "" {
				require.Error(t, err)
				assert.Contains(t, err.Error(), tt.wantErr)
				return
			}

			require.NoError(t, err)
			assert.Equal(t, tt.wantProvider, cfg.LLMProvider)
			assert.Equal(t, tt.wantModel, cfg.ClaudeModel)
			assert.Equal(t, tt.wantBedrockRegion, cfg.BedrockRegion)
		})
	}
}

func merge(base, overrides map[string]string) map[string]string {
	out := make(map[string]string, len(base)+len(overrides))
	for k, v := range base {
		out[k] = v
	}
	for k, v := range overrides {
		out[k] = v
	}
	return out
}

-  allKeys := []string{"TELEPORT_ADDR", "TELEPORT_IDENTITY_FILE", "ANTHROPIC_API_KEY", "CLAUDE_MODEL"}

+    "TELEPORT_ADDR", "TELEPORT_IDENTITY_FILE",

+    "LLM_PROVIDER", "ANTHROPIC_API_KEY", "BEDROCK_REGION", "CLAUDE_MODEL",

+    "TELEPORT_ADDR":          "tp.example.com:443",

+    "TELEPORT_IDENTITY_FILE": "/id.pem",

+    "ANTHROPIC_API_KEY":      "sk-ant-key",

-      name:    "missing ANTHROPIC_API_KEY",

+      name:    "missing ANTHROPIC_API_KEY with default provider",

-        "TELEPORT_ADDR":          "tp.example.com:443",

-        "TELEPORT_IDENTITY_FILE": "/id.pem",

-        "ANTHROPIC_API_KEY":      "sk-ant-key",

+      env:     merge(base, map[string]string{"LLM_PROVIDER": "openai"}),

-        "CLAUDE_MODEL":           "claude-opus-4-7",

+      name:         "defaults (anthropic)",

+      wantModel:    defaultModelAnthropic,

+      name:         "explicit anthropic provider",

+      env:          merge(base, map[string]string{"LLM_PROVIDER": "anthropic"}),

+      name:         "bedrock provider, no API key required",

+      env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock"},

+      wantModel:    defaultModelBedrock,

+      name:              "bedrock provider with region",

+      env:               map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "BEDROCK_REGION": "eu-west-1"},

+      wantProvider:      ProviderBedrock,

+      wantModel:         defaultModelBedrock,

+      name:         "custom model overrides provider default",

+      env:          merge(base, map[string]string{"CLAUDE_MODEL": "claude-opus-4-7"}),

+      name:         "bedrock with custom model",

+      env:          map[string]string{"TELEPORT_ADDR": "tp.example.com:443", "TELEPORT_IDENTITY_FILE": "/id.pem", "LLM_PROVIDER": "bedrock", "CLAUDE_MODEL": "us.anthropic.claude-3-7-sonnet-20250219-v1:0"},

+      wantModel:    "us.anthropic.claude-3-7-sonnet-20250219-v1:0",

+      assert.Equal(t, tt.wantProvider, cfg.LLMProvider)

+      assert.Equal(t, tt.wantBedrockRegion, cfg.BedrockRegion)

+func merge(base, overrides map[string]string) map[string]string {

+  out := make(map[string]string, len(base)+len(overrides))


Bash

Run all tests after Bedrock changes


?   	github.com/immuta/operations/services/televibe	[no test files]
ok  	github.com/immuta/operations/services/televibe/internal/config	0.428s
ok  	github.com/immuta/operations/services/televibe/internal/evaluator	0.663s
?   	github.com/immuta/operations/services/televibe/internal/watcher	[no test files]


All passing. Here's what changed:

---

**`internal/config/config.go`**

New env vars:
| Var | Default | Notes |
|---|---|---|
| `LLM_PROVIDER` | `anthropic` | `anthropic` or `bedrock` |
| `BEDROCK_REGION` | *(empty)* | Falls back to `AWS_DEFAULT_REGION` / credential chain if unset |
| `CLAUDE_MODEL` | provider-specific | `claude-sonnet-4-6` for Anthropic; `us.anthropic.claude-3-5-sonnet-20241022-v2:0` for Bedrock |

`ANTHROPIC_API_KEY` is only required when `LLM_PROVIDER=anthropic`. Bedrock uses the standard AWS credential chain (`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, instance role, etc.).

**`internal/evaluator/evaluator.go`**

`New` now accepts an injected `anthropic.Client` instead of building one internally — the provider decision is no longer the evaluator's concern.

**`main.go`**

Switches on `cfg.LLMProvider` to build the right client before wiring everything up:
```
LLM_PROVIDER=bedrock  → anthropic.NewClient(bedrock.WithLoadDefaultConfig(ctx, ...))
LLM_PROVIDER=anthropic → anthropic.NewClient(option.WithAPIKey(...))
```

The Bedrock model IDs use cross-region inference profile format — override `CLAUDE_MODEL` if you need a specific version (e.g. `us.anthropic.claude-3-7-sonnet-20250219-v1:0`).


Building a Go service called televibe that monitors Teleport access requests and uses Claude to score their risk. Both Anthropic API and AWS Bedrock are supported. Ready to commit or extend further. (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>