254 lines
6.3 KiB
Go
254 lines
6.3 KiB
Go
package litellm
|
|
|
|
// Model implements google.golang.org/adk/model.LLM against any
|
|
// OpenAI-compatible endpoint (LiteLLM, Ollama, vLLM, etc.).
|
|
//
|
|
// The official Go ADK v1.x ships only Gemini adapters. This adapter
|
|
// implements the official interface directly via net/http — no extra deps.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"iter"
|
|
"net/http"
|
|
|
|
adkmodel "google.golang.org/adk/model"
|
|
"google.golang.org/genai"
|
|
)
|
|
|
|
// Model is an ADK-compatible LLM backed by an OpenAI-compatible endpoint.
|
|
type Model struct {
|
|
name string
|
|
baseURL string
|
|
apiKey string
|
|
client *http.Client
|
|
}
|
|
|
|
// New creates an OpenAI-compatible ADK model.
|
|
// name is the model identifier sent in requests (e.g. "berget/llama-3.3-70b").
|
|
// baseURL is the API base without path (e.g. "https://llm-api.d-ma.be/v1").
|
|
func New(name, baseURL, apiKey string) *Model {
|
|
return &Model{name: name, baseURL: baseURL, apiKey: apiKey, client: &http.Client{}}
|
|
}
|
|
|
|
func (m *Model) Name() string { return m.name }
|
|
|
|
// --- OpenAI wire types (minimal subset ADK uses) ---
|
|
|
|
type oaiMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content,omitempty"`
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
ToolCalls []oaiToolCall `json:"tool_calls,omitempty"`
|
|
}
|
|
|
|
type oaiToolCall struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Function oaiFunctionCall `json:"function"`
|
|
}
|
|
|
|
type oaiFunctionCall struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
}
|
|
|
|
type oaiTool struct {
|
|
Type string `json:"type"`
|
|
Function oaiFunctionDef `json:"function"`
|
|
}
|
|
|
|
type oaiFunctionDef struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Parameters json.RawMessage `json:"parameters,omitempty"`
|
|
}
|
|
|
|
type oaiRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []oaiMessage `json:"messages"`
|
|
Tools []oaiTool `json:"tools,omitempty"`
|
|
}
|
|
|
|
type oaiChoice struct {
|
|
Message oaiMessage `json:"message"`
|
|
FinishReason string `json:"finish_reason"`
|
|
}
|
|
|
|
type oaiResponse struct {
|
|
Choices []oaiChoice `json:"choices"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
func (m *Model) GenerateContent(ctx context.Context, req *adkmodel.LLMRequest, _ bool) iter.Seq2[*adkmodel.LLMResponse, error] {
|
|
return func(yield func(*adkmodel.LLMResponse, error) bool) {
|
|
msgs := contentsToMessages(req.Contents)
|
|
tools := adk2oaiTools(req)
|
|
|
|
oaiReq := oaiRequest{Model: m.name, Messages: msgs, Tools: tools}
|
|
|
|
body, err := json.Marshal(oaiReq)
|
|
if err != nil {
|
|
yield(nil, fmt.Errorf("marshal: %w", err))
|
|
return
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
m.baseURL+"/chat/completions", bytes.NewReader(body))
|
|
if err != nil {
|
|
yield(nil, fmt.Errorf("new request: %w", err))
|
|
return
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Authorization", "Bearer "+m.apiKey)
|
|
|
|
resp, err := m.client.Do(httpReq)
|
|
if err != nil {
|
|
yield(nil, fmt.Errorf("http: %w", err))
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
yield(nil, fmt.Errorf("read body: %w", err))
|
|
return
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
yield(nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw)))
|
|
return
|
|
}
|
|
|
|
var oaiResp oaiResponse
|
|
if err := json.Unmarshal(raw, &oaiResp); err != nil {
|
|
yield(nil, fmt.Errorf("unmarshal: %w", err))
|
|
return
|
|
}
|
|
if oaiResp.Error != nil {
|
|
yield(nil, fmt.Errorf("api error: %s", oaiResp.Error.Message))
|
|
return
|
|
}
|
|
if len(oaiResp.Choices) == 0 {
|
|
yield(nil, fmt.Errorf("no choices in response"))
|
|
return
|
|
}
|
|
|
|
content := oaiChoiceToContent(oaiResp.Choices[0])
|
|
yield(&adkmodel.LLMResponse{Content: content, TurnComplete: true}, nil)
|
|
}
|
|
}
|
|
|
|
func contentsToMessages(contents []*genai.Content) []oaiMessage {
|
|
var msgs []oaiMessage
|
|
for _, c := range contents {
|
|
if c == nil {
|
|
continue
|
|
}
|
|
var textBuf bytes.Buffer
|
|
var toolCalls []oaiToolCall
|
|
|
|
for _, p := range c.Parts {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
if p.Text != "" {
|
|
textBuf.WriteString(p.Text)
|
|
}
|
|
if p.FunctionCall != nil {
|
|
argBytes, _ := json.Marshal(p.FunctionCall.Args)
|
|
toolCalls = append(toolCalls, oaiToolCall{
|
|
ID: p.FunctionCall.ID,
|
|
Type: "function",
|
|
Function: oaiFunctionCall{
|
|
Name: p.FunctionCall.Name,
|
|
Arguments: string(argBytes),
|
|
},
|
|
})
|
|
}
|
|
if p.FunctionResponse != nil {
|
|
respBytes, _ := json.Marshal(p.FunctionResponse.Response)
|
|
msgs = append(msgs, oaiMessage{
|
|
Role: "tool",
|
|
Content: string(respBytes),
|
|
ToolCallID: p.FunctionResponse.ID,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(toolCalls) > 0 || textBuf.Len() > 0 {
|
|
msg := oaiMessage{Role: c.Role}
|
|
if c.Role == "model" {
|
|
msg.Role = "assistant"
|
|
}
|
|
msg.Content = textBuf.String()
|
|
msg.ToolCalls = toolCalls
|
|
msgs = append(msgs, msg)
|
|
}
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
func adk2oaiTools(req *adkmodel.LLMRequest) []oaiTool {
|
|
if len(req.Tools) == 0 {
|
|
return nil
|
|
}
|
|
var tools []oaiTool
|
|
for name, def := range req.Tools {
|
|
raw, _ := json.Marshal(def)
|
|
var m map[string]json.RawMessage
|
|
_ = json.Unmarshal(raw, &m)
|
|
var desc string
|
|
if d, ok := m["description"]; ok {
|
|
_ = json.Unmarshal(d, &desc)
|
|
}
|
|
params := m["parameters"]
|
|
// Some endpoints (e.g. Berget) reject null parameters for zero-arg tools.
|
|
if len(params) == 0 || string(params) == "null" {
|
|
params = json.RawMessage(`{"type":"object","properties":{}}`)
|
|
}
|
|
tools = append(tools, oaiTool{
|
|
Type: "function",
|
|
Function: oaiFunctionDef{
|
|
Name: name,
|
|
Description: desc,
|
|
Parameters: params,
|
|
},
|
|
})
|
|
}
|
|
return tools
|
|
}
|
|
|
|
func oaiChoiceToContent(choice oaiChoice) *genai.Content {
|
|
msg := choice.Message
|
|
var parts []*genai.Part
|
|
|
|
if msg.Content != "" {
|
|
parts = append(parts, &genai.Part{Text: msg.Content})
|
|
}
|
|
for _, tc := range msg.ToolCalls {
|
|
var args map[string]any
|
|
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
parts = append(parts, &genai.Part{
|
|
FunctionCall: &genai.FunctionCall{
|
|
ID: tc.ID,
|
|
Name: tc.Function.Name,
|
|
Args: args,
|
|
},
|
|
})
|
|
}
|
|
|
|
role := msg.Role
|
|
if role == "assistant" {
|
|
role = "model"
|
|
}
|
|
return &genai.Content{Role: role, Parts: parts}
|
|
}
|