// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package tool import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/schema" ) const qweatherToolName = "qweather" const qweatherToolDescription = "Fetch current weather conditions from 和风天气 (QWeather, devapi.qweather.com). " + "Returns {temp, feelsLike, text, windDir, humidity}." // qweatherEndpoint is the QWeather v7 "now" endpoint. Exposed as a // package var so tests can substitute a httptest.Server URL. var qweatherEndpoint = "https://devapi.qweather.com/v7/weather/now" // qweatherParams is the JSON shape the model sends into InvokableRun. // // - APIKey (required): the QWeather API key (Console → Project → Credentials). // - Location (required): the location code, e.g. "101010100" (Beijing) // or a lat,lon string "39.904,116.405". // - Lang (optional): language code for `text` and `windDir`. Defaults // to "zh" per the inbox spec. type qweatherParams struct { APIKey string `json:"api_key"` Location string `json:"location"` Lang string `json:"lang,omitempty"` } // qweatherNow is the upstream `now` object. type qweatherNow struct { Temp string `json:"temp"` // "23" FeelsLike string `json:"feelsLike"` // "22" Text string `json:"text"` // "多云" WindDir string `json:"windDir"` // "东南风" Humidity string `json:"humidity"` // "65" } // qweatherResponse is the upstream QWeather envelope. We model only // the fields the inbox spec calls out; additional fields from QWeather // (obsTime, precip, pressure, ...) are ignored. type qweatherResponse struct { Code string `json:"code"` // "200" = OK Now qweatherNow `json:"now"` } // qweatherEnvelope is the model-facing JSON shape. type qweatherEnvelope struct { Temp string `json:"temp,omitempty"` FeelsLike string `json:"feels_like,omitempty"` Text string `json:"text,omitempty"` WindDir string `json:"wind_dir,omitempty"` Humidity string `json:"humidity,omitempty"` Error string `json:"_ERROR,omitempty"` } // QWeatherTool is the 和风天气 // (QWeather) current-conditions tool ( // batch 4). It performs a GET against devapi.qweather.com/v7/weather/now // and returns the parsed now.{temp, feelsLike, text, windDir, humidity}. // // QWeatherTool uses the shared HTTPHelper for retry/timeout/OTel // propagation. type QWeatherTool struct { helper *HTTPHelper } // NewQWeatherTool returns a QWeatherTool using the default HTTPHelper. func NewQWeatherTool() *QWeatherTool { return NewQWeatherToolWith(NewHTTPHelper()) } // NewQWeatherToolWith returns a QWeatherTool that uses the provided // HTTPHelper. Useful for tests. func NewQWeatherToolWith(h *HTTPHelper) *QWeatherTool { if h == nil { h = NewHTTPHelper() } return &QWeatherTool{helper: h} } // Info returns the tool's metadata for the chat model. func (q *QWeatherTool) Info(_ context.Context) (*schema.ToolInfo, error) { return &schema.ToolInfo{ Name: qweatherToolName, Desc: qweatherToolDescription, ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ "api_key": { Type: schema.String, Desc: "QWeather API key (Console → 项目 → 凭据).", Required: true, }, "location": { Type: schema.String, Desc: "Location code (e.g. 101010100 for Beijing) or \"lat,lon\" (e.g. \"39.904,116.405\").", Required: true, }, "lang": { Type: schema.String, Desc: "Language for `text` and `windDir`. Defaults to \"zh\".", Required: false, }, }), }, nil } // buildQWeatherURL composes the devapi.qweather.com URL. Centralized // for testability. func buildQWeatherURL(p qweatherParams) string { q := url.Values{} q.Set("location", p.Location) q.Set("key", p.APIKey) if p.Lang == "" { p.Lang = "zh" } q.Set("lang", p.Lang) return qweatherEndpoint + "?" + q.Encode() } // InvokableRun performs the QWeather current-conditions GET. func (q *QWeatherTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.Option) (string, error) { var p qweatherParams if err := json.Unmarshal([]byte(argsJSON), &p); err != nil { return qweatherErrJSON(fmt.Errorf("qweather: parse arguments: %w", err)), fmt.Errorf("qweather: parse arguments: %w", err) } if p.APIKey == "" { return qweatherErrJSON(fmt.Errorf("qweather: api_key is required")), fmt.Errorf("qweather: api_key is required") } if p.Location == "" { return qweatherErrJSON(fmt.Errorf("qweather: location is required")), fmt.Errorf("qweather: location is required") } endpoint := buildQWeatherURL(p) headers := map[string]string{ "Accept": "application/json", } resp, err := q.helper.Do(ctx, http.MethodGet, endpoint, "", "", headers) if err != nil { return qweatherErrJSON(err), err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return qweatherErrJSON(fmt.Errorf("qweather: upstream returned %d", resp.StatusCode)), fmt.Errorf("qweather: upstream returned %d", resp.StatusCode) } var raw qweatherResponse if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { return qweatherErrJSON(fmt.Errorf("qweather: decode response: %w", err)), fmt.Errorf("qweather: decode response: %w", err) } // QWeather uses "200" string code for OK; anything else is a // business-level error. Code "404" = city not found, "401" = authentication failed, // "429" = exceeded access count, "402" = exceeded access rate. if raw.Code != "200" { return qweatherErrJSON(fmt.Errorf("qweather: upstream returned code %q", raw.Code)), fmt.Errorf("qweather: upstream returned code %q", raw.Code) } return qweatherJSON(qweatherEnvelope{ Temp: raw.Now.Temp, FeelsLike: raw.Now.FeelsLike, Text: raw.Now.Text, WindDir: raw.Now.WindDir, Humidity: raw.Now.Humidity, }), nil } func qweatherJSON(env qweatherEnvelope) string { b, err := json.Marshal(env) if err != nil { return fmt.Sprintf(`{"_ERROR":"qweather: marshal result: %s"}`, err) } return string(b) } func qweatherErrJSON(err error) string { return qweatherJSON(qweatherEnvelope{Error: err.Error()}) } // formatQWeatherError joins a non-2xx status with the upstream code so // the model can see both signals. Exposed for testability. func formatQWeatherError(status int, upstreamCode string) string { if strings.TrimSpace(upstreamCode) == "" { return fmt.Sprintf("qweather: upstream returned %d", status) } return fmt.Sprintf("qweather: upstream returned %d (code=%s)", status, upstreamCode) }