Skip to content

Commit a352878

Browse files
committed
Add (real, non-crypto) currency conversion.
Signed-off-by: Katharine Berry <[email protected]>
1 parent ec8443f commit a352878

File tree

6 files changed

+500
-14
lines changed

6 files changed

+500
-14
lines changed

service/assistant/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type Config struct {
2222
GeminiKey string
2323
MapboxKey string
2424
IBMKey string
25+
ExchangeRateApiKey string
2526
RedisURL string
2627
UserIdentificationURL string
2728
HoneycombKey string
@@ -38,6 +39,7 @@ func init() {
3839
GeminiKey: os.Getenv("GEMINI_KEY"),
3940
MapboxKey: os.Getenv("MAPBOX_KEY"),
4041
IBMKey: os.Getenv("IBM_KEY"),
42+
ExchangeRateApiKey: os.Getenv("EXCHANGE_RATE_API_KEY"),
4143
RedisURL: os.Getenv("REDIS_URL"),
4244
UserIdentificationURL: os.Getenv("USER_IDENTIFICATION_URL"),
4345
HoneycombKey: os.Getenv("HONEYCOMB_KEY"),
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package functions
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"log"
21+
22+
"github.com/honeycombio/beeline-go"
23+
"google.golang.org/genai"
24+
25+
"github.com/pebble-dev/bobby-assistant/service/assistant/quota"
26+
"github.com/pebble-dev/bobby-assistant/service/assistant/util/currencies"
27+
)
28+
29+
type CurrencyConversionRequest struct {
30+
Amount float64
31+
From string
32+
To string
33+
}
34+
35+
type CurrencyConversionResponse struct {
36+
Amount string // we return a nicely formatted string for the LLM's benefit
37+
Currency string
38+
}
39+
40+
func init() {
41+
registerFunction(Registration{
42+
Definition: genai.FunctionDeclaration{
43+
Name: "convert_currency",
44+
Description: "Convert an amount of one (real, non-crypto) currency to another. *Always* call this function to get exchange rates when doing currency conversion - never use memorised rates.",
45+
Parameters: &genai.Schema{
46+
Type: genai.TypeObject,
47+
Nullable: false,
48+
Properties: map[string]*genai.Schema{
49+
"amount": {
50+
Type: genai.TypeNumber,
51+
Format: "double",
52+
Description: "The amount of currency to convert.",
53+
Nullable: false,
54+
},
55+
"from": {
56+
Type: genai.TypeString,
57+
Description: "The currency code to convert from.",
58+
Nullable: false,
59+
},
60+
"to": {
61+
Type: genai.TypeString,
62+
Description: "The currency code to convert to.",
63+
},
64+
},
65+
Required: []string{"amount", "from", "to"},
66+
},
67+
},
68+
Fn: convertCurrency,
69+
Thought: convertCurrencyThought,
70+
InputType: CurrencyConversionRequest{},
71+
})
72+
}
73+
74+
func convertCurrency(ctx context.Context, qt *quota.Tracker, input interface{}) interface{} {
75+
ctx, span := beeline.StartSpan(ctx, "convert_currency")
76+
defer span.Send()
77+
ccr := input.(*CurrencyConversionRequest)
78+
79+
if !currencies.IsValidCurrency(ccr.From) {
80+
return Error{Error: "Unknown currency code " + ccr.From}
81+
}
82+
if !currencies.IsValidCurrency(ccr.To) {
83+
return Error{Error: "Unknown currency code " + ccr.To}
84+
}
85+
86+
cdm := currencies.GetCurrencyDataManager()
87+
88+
data, err := cdm.GetExchangeData(ctx, ccr.From)
89+
if err != nil {
90+
log.Printf("error getting currency data for %s/%s: %v", ccr.From, ccr.To, err)
91+
return Error{Error: err.Error()}
92+
}
93+
if data == nil {
94+
return Error{Error: "returned currency data is nil!?"}
95+
}
96+
97+
rate, ok := data.ConversionRates[ccr.To]
98+
if !ok {
99+
return Error{Error: fmt.Sprintf("No currency conversion available from %s to %s", ccr.From, ccr.To)}
100+
}
101+
102+
result := rate * ccr.Amount
103+
return &CurrencyConversionResponse{
104+
Amount: fmt.Sprintf("%.2f", result),
105+
Currency: ccr.To,
106+
}
107+
}
108+
109+
func convertCurrencyThought(input interface{}) string {
110+
return "Checking rates..."
111+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package currencies
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"errors"
21+
"fmt"
22+
"net/http"
23+
"net/url"
24+
"sync"
25+
"time"
26+
27+
"github.com/honeycombio/beeline-go"
28+
"github.com/redis/go-redis/v9"
29+
30+
"github.com/pebble-dev/bobby-assistant/service/assistant/config"
31+
"github.com/pebble-dev/bobby-assistant/service/assistant/util/storage"
32+
)
33+
34+
type CurrencyExchangeData struct {
35+
Result string `json:"result"`
36+
ErrorType string `json:"error-type,omitempty"`
37+
TimeLastUpdateUnix int `json:"time_last_update_unix"`
38+
TimeNextUpdateUnix int `json:"time_next_update_unix"`
39+
BaseCode string `json:"base_code"`
40+
ConversionRates map[string]float64 `json:"conversion_rates"`
41+
}
42+
43+
var sharedCurrencyDataManager *DataManager
44+
var sharedCurrencyDataManagerOnce sync.Once
45+
46+
func GetCurrencyDataManager() *DataManager {
47+
sharedCurrencyDataManagerOnce.Do(func() {
48+
sharedCurrencyDataManager = &DataManager{
49+
redisClient: storage.GetRedis(),
50+
}
51+
})
52+
return sharedCurrencyDataManager
53+
}
54+
55+
type DataManager struct {
56+
redisClient *redis.Client
57+
}
58+
59+
var ErrUnknownCurrency = errors.New("unknown currency code")
60+
var ErrQuotaExceeded = errors.New("quota exceeded")
61+
62+
func (dm *DataManager) GetExchangeData(ctx context.Context, from string) (*CurrencyExchangeData, error) {
63+
ctx, span := beeline.StartSpan(ctx, "get_exchange_data")
64+
defer span.Send()
65+
if !IsValidCurrency(from) {
66+
return nil, fmt.Errorf("unknown currency code %q", from)
67+
}
68+
data, err := dm.loadCachedData(ctx, from)
69+
if err != nil {
70+
return nil, fmt.Errorf("couldn't load cached data: %w", err)
71+
}
72+
if data != nil {
73+
return data, nil
74+
}
75+
// TODO: if we had high usage, we should prevent multiple concurrent requests for the same currency
76+
// but in practice this seems unlikely to be a major issue at our anticipated scale.
77+
data, err = dm.fetchExchangeRateData(ctx, from)
78+
if err != nil {
79+
return nil, fmt.Errorf("couldn't fetch exchange rate data: %w", err)
80+
}
81+
if err := dm.cacheData(ctx, from, data); err != nil {
82+
return nil, fmt.Errorf("error caching exchange rate data: %w", err)
83+
}
84+
return data, nil
85+
}
86+
87+
func (dm *DataManager) fetchExchangeRateData(ctx context.Context, from string) (*CurrencyExchangeData, error) {
88+
ctx, span := beeline.StartSpan(ctx, "fetch_exchange_rate_data")
89+
defer span.Send()
90+
escaped := url.QueryEscape(from)
91+
request, err := http.NewRequest("GET", "https://v6.exchangerate-api.com/v6/"+config.GetConfig().ExchangeRateApiKey+"/latest/"+escaped, nil)
92+
if err != nil {
93+
return nil, err
94+
}
95+
resp, err := http.DefaultClient.Do(request)
96+
if err != nil {
97+
return nil, err
98+
}
99+
defer resp.Body.Close()
100+
var data CurrencyExchangeData
101+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
102+
return nil, err
103+
}
104+
if data.Result != "success" {
105+
if data.Result != "error" {
106+
return nil, fmt.Errorf("unexpected result %q", data.Result)
107+
}
108+
switch data.ErrorType {
109+
case "unsupported-code":
110+
return nil, ErrUnknownCurrency
111+
case "quota-reached":
112+
return nil, ErrQuotaExceeded
113+
default:
114+
return nil, fmt.Errorf("error fetching currency data: %s", data.ErrorType)
115+
}
116+
}
117+
return &data, nil
118+
}
119+
120+
func (dm *DataManager) cacheData(ctx context.Context, currency string, data *CurrencyExchangeData) error {
121+
ctx, span := beeline.StartSpan(ctx, "cache_data")
122+
defer span.Send()
123+
encoded, err := json.Marshal(data)
124+
if err != nil {
125+
return err
126+
}
127+
expirationTime := time.Unix(int64(data.TimeNextUpdateUnix+5), 0)
128+
if err := dm.redisClient.Set(ctx, keyFromCurrency(currency), encoded, expirationTime.Sub(time.Now())).Err(); err != nil {
129+
return err
130+
}
131+
return nil
132+
}
133+
134+
func (dm *DataManager) loadCachedData(ctx context.Context, currency string) (*CurrencyExchangeData, error) {
135+
ctx, span := beeline.StartSpan(ctx, "load_cached_data")
136+
defer span.Send()
137+
data, err := dm.redisClient.Get(ctx, keyFromCurrency(currency)).Result()
138+
if err != nil {
139+
if errors.Is(err, redis.Nil) {
140+
return nil, nil
141+
}
142+
return nil, err
143+
}
144+
var decoded CurrencyExchangeData
145+
if err := json.Unmarshal([]byte(data), &decoded); err != nil {
146+
return nil, err
147+
}
148+
return &decoded, nil
149+
}
150+
151+
func keyFromCurrency(from string) string {
152+
return "currency:" + from
153+
}

0 commit comments

Comments
 (0)