Files
Mathias Bergqvist 7dfe8a792e feat: initial scaffold with context adapters and litellm pkg
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:02:07 +02:00

251 lines
6.2 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)
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}
}