Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

845
pkg/vllmkit/client.go Normal file
View File

@@ -0,0 +1,845 @@
// Package vllmkit talks to the production vLLM OpenAI-compatible
// inference servers that host Qwen 3.5 (chat + tool calling) and a
// separate embedding model. Shared by the agent service (ChatStream)
// and the knowledge service (Embed) — the same internal/llm.Client the
// monolith had, split out since both of those bounded contexts need it
// and neither should depend on the other.
//
// This package previously spoke Ollama's native /api/chat and
// /api/embed protocol directly against a single local daemon. It now
// speaks the OpenAI /v1/chat/completions and /v1/embeddings wire
// format that `vllm serve` exposes, against two independently scaled,
// independently deployed vLLM servers — one per model, since a single
// vLLM engine process serves exactly one model. See
// ENKLY_VLLM_CHAT_URL / ENKLY_VLLM_EMBED_URL in cmd/server/main.go and
// k8s/11-vllm.yaml for how those servers are configured and deployed.
package vllmkit
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"sort"
"strings"
"sync"
"time"
)
const (
// DefaultChatModel is the served model name the chat vLLM
// deployment must be started with (`vllm serve ... --served-model-name
// qwen3.5`). vLLM matches the request body's "model" field against
// that flag's value, not against a Hugging Face repo id, so this
// is a deployment-chosen alias, not a model identifier — keep it
// in sync with k8s/11-vllm.yaml's --served-model-name.
DefaultChatModel = "qwen3.5"
// DefaultEmbedModel is the served model name for the embedding
// vLLM deployment. vLLM's pooling/embedding model support covers
// nomic-ai/nomic-embed-text-v1.5 (what this deployment actually
// runs; it needs `--trust-remote-code` since Nomic's config uses
// custom modeling code) — small, fast, and a good CPU/GPU-both
// fit. Update the `vector(768)` dimension in schema.sql first if
// you point this at a model with a different output size.
DefaultEmbedModel = "nomic-embed-text"
)
// Options mirrors the subset of the OpenAI-compatible chat-completions
// request body that's worth tuning per call. Unlike Ollama's
// num_ctx/num_gpu/num_thread, context length and GPU/tensor-parallel
// layout are not per-request knobs under vLLM — they're fixed at
// server start (--max-model-len, --tensor-parallel-size,
// --gpu-memory-utilization; see k8s/11-vllm.yaml) and apply to every
// request the deployment serves. Zero values are omitted from the wire
// request, so the server's own defaults apply unless explicitly
// overridden — see the ENKLY_CHAT_* env vars wired up in
// cmd/server/main.go.
type Options struct {
Temperature float64 // sampling temperature (temperature)
TopP float64 // nucleus sampling cutoff (top_p)
MaxTokens int // max tokens to generate this turn (max_tokens); 0 = server default
}
// Client is a thin wrapper around two vLLM OpenAI-compatible HTTP
// APIs: one serving the chat model, one serving the embedding model.
type Client struct {
// ChatBaseURL / EmbedBaseURL point at each vLLM deployment's
// OpenAI-compatible router, e.g. "https://vllm-chat.enkly.org/v1" and
// "https://vllm-embed.enkly.org/v1". They are deliberately separate
// fields (rather than one shared host) because a chat model and
// an embedding model are different vLLM engine processes in
// production — usually on different Services/Deployments with
// different GPU shapes — not two endpoints behind the same daemon
// the way Ollama modeled it.
ChatBaseURL string
EmbedBaseURL string
ChatModel string
EmbedModel string
HTTP *http.Client
// APIKey, when set, is sent as "Authorization: Bearer <APIKey>" on
// every request — vLLM's --api-key flag. Leave empty for a
// cluster-internal deployment with no auth in front of it (the
// Service is only reachable from inside the mesh/namespace); set
// it for anything exposed beyond that boundary. Set via
// ENKLY_VLLM_API_KEY.
APIKey string
// Think controls extended "thinking" on chat requests via vLLM's
// chat_template_kwargs extra-body extension: "" leaves it to the
// chat template's own default (Qwen3's thinking-capable template
// defaults to thinking on), "true"/"false" (and the usual
// on/off/yes/no spellings) force it on/off. A "low"/"medium"/
// "high" effort string (see pkg/effort) is also accepted for
// compatibility with the Anthropic-style effort vocabulary the
// rest of the agent uses — vLLM/Qwen3's reasoning toggle itself is
// a single on/off switch with no server-side notion of graded
// effort, so any non-empty, non-"false" level here just turns
// thinking on; depth is instead shaped by Options.MaxTokens and
// agentrun's own round/compaction budgets (see effort.Profile).
// When on, the reasoning trace streams back separately from the
// answer, in OpenAI's "reasoning_content" delta field
// (ChatEvent.Thinking) — requires the chat deployment to be
// started with a reasoning parser, e.g. `--reasoning-parser
// qwen3`. Set via ENKLY_CHAT_THINK.
Think string
// ChatOptions is sent as the top-level temperature/top_p/max_tokens
// fields of the chat-completions request.
ChatOptions Options
// embedCache memoizes Embed() results within this process's
// lifetime, keyed by (model, text) hash. Embedding the exact same
// string twice is common — repeated search_codebase queries within
// a conversation, or an unchanged chunk re-indexed after a
// no-op edit that rag.Store's content-hash check didn't already
// short-circuit — and skipping the round trip to vLLM for a
// repeat is a pure latency win with no correctness downside, since
// embeddings are a deterministic function of (model, text).
embedCache *embedCache
}
// New builds a Client pointed at the given chat and embed vLLM
// deployments, with connection reuse tuned for the request pattern
// here: a handful of long-lived streaming chat requests plus frequent,
// short embedding calls, each against its own vLLM host. Either URL
// may be left empty if this Client will only ever be used for the
// other call (e.g. the knowledge service never calls ChatStream, so it
// passes "" for chatBaseURL).
func New(chatBaseURL, embedBaseURL, chatModel, embedModel string) *Client {
if chatBaseURL == "" {
chatBaseURL = "http://localhost:8000/v1"
}
if embedBaseURL == "" {
embedBaseURL = "http://localhost:8001/v1"
}
if chatModel == "" {
chatModel = DefaultChatModel
}
if embedModel == "" {
embedModel = DefaultEmbedModel
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 60 * time.Second,
}).DialContext,
// Two vLLM hosts, but chat + embed + concurrent requests (see
// agent.go's parallel tool execution, which can itself fan out
// concurrent search_codebase embed calls) can be in flight
// together — a small pool avoids a fresh TCP+HTTP handshake on
// every call, a real chunk of latency in practice even against
// a fast in-cluster backend.
MaxIdleConns: 64,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
}
return &Client{
ChatBaseURL: chatBaseURL,
EmbedBaseURL: embedBaseURL,
ChatModel: chatModel,
EmbedModel: embedModel,
HTTP: &http.Client{
Transport: transport,
// No overall timeout: chat streams can legitimately run for
// minutes while the agent loop executes several tool calls.
// Callers should thread a context deadline through ctx instead.
},
embedCache: newEmbedCache(500),
}
}
func (c *Client) authHeader(req *http.Request) {
if c.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.APIKey)
}
}
// ---------------------------------------------------------------------
// Chat (streaming, with tool calling) — OpenAI /v1/chat/completions
// ---------------------------------------------------------------------
// wireToolCall is the OpenAI wire shape for a tool call, both on a
// complete assistant message (echoed back into history) and,
// incrementally, on a streamed delta (see toolCallAccumulator).
type wireToolCall struct {
Index *int `json:"index,omitempty"` // present only on streamed deltas
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"` // always "function"
Function wireToolCallFunction `json:"function"`
}
type wireToolCallFunction struct {
Name string `json:"name,omitempty"`
// Arguments is a JSON-encoded string on the wire (OpenAI's
// function-calling contract), not a nested object the way Ollama's
// native API sent it — toWireMessages/fromWireToolCalls marshal
// and unmarshal it at this package's boundary so the rest of the
// codebase still deals in map[string]any.
Arguments string `json:"arguments,omitempty"`
}
type wireMessage struct {
Role string `json:"role"`
Content *string `json:"content"` // nil (omitted) for an assistant message that is tool_calls-only
ToolCalls []wireToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"` // role="tool" only
Name string `json:"name,omitempty"` // role="tool" only; some parsers want the tool name too
}
type wireToolDef struct {
Type string `json:"type"` // always "function"
Function wireToolFunction `json:"function"`
}
type wireToolFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []wireMessage `json:"messages"`
Tools []wireToolDef `json:"tools,omitempty"`
Stream bool `json:"stream"`
// ChatTemplateKwargs is vLLM's extra-body extension point for
// per-request chat-template flags; "enable_thinking" is how
// Qwen3's template (and vLLM's qwen3 reasoning parser) take the
// thinking on/off toggle. Opaque to the OpenAI spec itself, so
// this is marshaled as a bare map rather than a typed field.
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
}
// chatStreamChunk is one Server-Sent-Events "data:" line of an OpenAI
// streaming chat-completions response.
type chatStreamChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []wireToolCall `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
// Error is set when a vLLM server streams a mid-response failure
// as a normal 200 SSE line rather than an HTTP error status (seen
// e.g. when the tool-call parser can't make sense of the model's
// raw output partway through generation).
Error *wireError `json:"error"`
}
// ErrUpstreamUnavailable wraps a transport-level failure to reach a
// vLLM deployment at all — connection refused, DNS failure, dial/TLS
// timeout — as opposed to vLLM responding with an error status or a
// malformed body. This is exactly the signature of a vllm-chat/
// vllm-embed pod that's Pending, still cold-starting (model load, see
// k8s/11-vllm.yaml's startupProbe), or mid-rollout (Recreate strategy
// briefly has zero replicas) — not a request vLLM itself rejected.
//
// Callers use IsUpstreamUnavailable to tell the two apart: the agent
// service turns this into a short "model is starting up" message for
// the end user instead of a raw dial error (see
// services/agent/internal/application/service.go), and the knowledge
// service's gRPC layer maps it onto codes.Unavailable (safe to retry)
// instead of codes.Internal (opaque failure).
var ErrUpstreamUnavailable = errors.New("vllm: upstream unreachable")
// IsUpstreamUnavailable reports whether err is, or wraps,
// ErrUpstreamUnavailable.
func IsUpstreamUnavailable(err error) bool { return errors.Is(err, ErrUpstreamUnavailable) }
// looksTransient reports whether err is a transport-level failure
// (connection refused, DNS, dial/handshake timeout, connection reset)
// worth a couple of quick retries, as opposed to an error vLLM itself
// returned after accepting the connection.
func looksTransient(err error) bool {
if err == nil {
return false
}
var netErr net.Error
if errors.As(err, &netErr) {
return true // covers dial timeout/refused, DNS failure, TLS handshake failure, etc.
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "no such host") ||
strings.Contains(msg, "connection reset")
}
// retryDelays bounds how long doWithRetry rides out a transient
// failure before giving up: short and few on purpose. This exists to
// smooth over a brief reconnect window — e.g. a Recreate rollout's old
// pod having just terminated — not to mask a genuinely Pending pod,
// which is a scheduling problem no amount of HTTP retrying fixes (see
// k8s/scripts/gpu-preflight.sh for that diagnosis).
var retryDelays = []time.Duration{200 * time.Millisecond, 600 * time.Millisecond}
// doWithRetry POSTs buf as JSON to url, retrying only on a transient
// transport failure (see looksTransient) — never after a response has
// actually been received, since a non-2xx status or a stream already
// in progress isn't safe or meaningful to blindly resend.
func (c *Client) doWithRetry(ctx context.Context, url string, buf []byte, sse bool) (*http.Response, error) {
for attempt := 0; ; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if sse {
req.Header.Set("Accept", "text/event-stream")
}
c.authHeader(req)
resp, err := c.HTTP.Do(req)
if err == nil {
return resp, nil
}
if attempt >= len(retryDelays) || !looksTransient(err) {
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(retryDelays[attempt]):
}
}
}
type wireError struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
}
func (e *wireError) Error() string {
if e == nil {
return ""
}
if e.Type != "" {
return fmt.Sprintf("%s: %s", e.Type, e.Message)
}
return e.Message
}
// errMalformedToolArgs is wrapped into the error ChatStream reports
// when vLLM's tool-call parser hands back an "arguments" string that
// doesn't parse as JSON. This is this package's own client-side
// detection of a broken tool-call round (vLLM never surfaces this as a
// distinct HTTP/wire error itself) — see IsToolCallParseError.
var errMalformedToolArgs = errors.New("vllm: tool call arguments were not valid JSON")
// toolCallParseErrPattern matches the family of messages vLLM's
// OpenAI-compatible server reports when a configured tool-call parser
// (hermes, llama3_json, qwen3, mistral, …) fails to extract a
// structured call from the model's raw generation — e.g. "Failed to
// parse tool calls", "could not parse function call", "invalid tool
// call arguments produced by model". Server-side wording differs by
// parser plugin and vLLM version, so this matches on the stable,
// shared vocabulary ("tool call" / "function call" alongside "parse"
// or "invalid") rather than one exact phrasing.
func toolCallParseErrPattern(msg string) bool {
msg = strings.ToLower(msg)
mentionsCall := strings.Contains(msg, "tool call") || strings.Contains(msg, "tool_call") ||
strings.Contains(msg, "function call") || strings.Contains(msg, "function_call")
mentionsFailure := strings.Contains(msg, "parse") || strings.Contains(msg, "invalid") || strings.Contains(msg, "malformed")
return mentionsCall && mentionsFailure
}
// IsToolCallParseError reports whether err is a transient failure to
// extract a structured tool call from the model's output — either
// vLLM's own tool-call parser rejecting the raw generation server-side
// (see toolCallParseErrPattern), or this client failing to decode the
// "arguments" JSON string vLLM did return (errMalformedToolArgs). Both
// are almost always a one-off decoding hiccup — something in the
// model's raw output didn't match what the configured parser expected
// — rather than a reason to give up the whole agent turn. Callers
// (agentrun.Agent.Run) use this to retry the round, and if it keeps
// recurring, to simplify the most recent tool results before one final
// retry.
//
// This replaces IsTemplateParseError, which detected the equivalent
// failure mode under Ollama's native chat-template/XML tool-call
// encoding — a protocol vLLM's OpenAI-compatible server doesn't use,
// so that detection logic (XML syntax errors, angle-bracket tag
// sniffing) no longer applies, but the role it plays in the retry loop
// is unchanged.
func IsToolCallParseError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, errMalformedToolArgs) {
return true
}
return toolCallParseErrPattern(err.Error())
}
// thinkWire maps a Think setting onto vLLM's chat_template_kwargs
// extra body. It returns nil when thinking should be left to the
// template's own default (omitting chat_template_kwargs entirely), and
// otherwise a map with "enable_thinking" set to true/false.
//
// override, when non-empty, takes precedence over c.Think for this one
// call — see ChatStream's think parameter. This is what lets a caller
// (agentrun.Agent's ThinkPolicy) dial reasoning on or off on a
// per-round basis — e.g. thinking on for the first, planning round of
// an agentic tool-use turn, thinking off for the rounds that just
// digest a tool result.
func thinkWire(setting, override string) map[string]any {
s := override
if s == "" {
s = setting
}
switch strings.ToLower(strings.TrimSpace(s)) {
case "":
return nil
case "false", "off", "0", "no":
return map[string]any{"enable_thinking": false}
default:
// "true"/"on"/"1"/"yes" and the effort levels ("low", "medium",
// "high", …) all just mean "thinking on" — see Think's doc
// comment for why vLLM/Qwen3 has no graded-effort equivalent.
return map[string]any{"enable_thinking": true}
}
}
// ChatEvent is one item streamed back from ChatStream: either a token of
// assistant content, a batch of tool calls the model wants executed, or
// the terminal done/error signal.
type ChatEvent struct {
Token string
Thinking string // a chunk of the model's reasoning trace (when thinking is on)
ToolCalls []ToolCallRequest
Done bool
Err error
}
// ChatStream sends the full message history (plus tool definitions) to
// the chat vLLM deployment and streams the reply back over the
// returned channel. The channel is closed after a Done or Err event.
//
// think overrides c.Think for this one call when non-empty (see
// thinkWire); pass "" to just use the Client's configured default.
func (c *Client) ChatStream(ctx context.Context, history []Message, tools []ToolDef, think string) (<-chan ChatEvent, error) {
reqBody := chatRequest{
Model: c.ChatModel,
Messages: toWireMessages(history),
Tools: toWireTools(tools),
Stream: true,
ChatTemplateKwargs: thinkWire(c.Think, think),
}
reqBody.Temperature = c.ChatOptions.Temperature
reqBody.TopP = c.ChatOptions.TopP
reqBody.MaxTokens = c.ChatOptions.MaxTokens
buf, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("llm: marshal chat request: %w", err)
}
resp, err := c.doWithRetry(ctx, c.ChatBaseURL+"/chat/completions", buf, true)
if err != nil {
if looksTransient(err) {
return nil, fmt.Errorf("llm: chat request failed (is vLLM running at %s?): %w: %w", c.ChatBaseURL, err, ErrUpstreamUnavailable)
}
return nil, fmt.Errorf("llm: chat request failed (is vLLM running at %s?): %w", c.ChatBaseURL, err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body := readErrorBody(resp)
if resp.StatusCode == http.StatusServiceUnavailable {
return nil, fmt.Errorf("llm: vllm returned %s: %s: %w", resp.Status, body, ErrUpstreamUnavailable)
}
return nil, fmt.Errorf("llm: vllm returned %s: %s", resp.Status, body)
}
events := make(chan ChatEvent, 16)
go func() {
defer resp.Body.Close()
defer close(events)
// send delivers an event unless the consumer has gone away (ctx
// cancelled), so this goroutine can never block forever on a
// channel nobody is reading.
send := func(ev ChatEvent) bool {
select {
case events <- ev:
return true
case <-ctx.Done():
return false
}
}
acc := newToolCallAccumulator()
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
sawDone := false
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 {
continue
}
data, ok := bytes.CutPrefix(line, []byte("data:"))
if !ok {
// Non-"data:" SSE lines (comments, "event:", blank
// keep-alives) carry nothing this client needs.
continue
}
data = bytes.TrimSpace(data)
if string(data) == "[DONE]" {
sawDone = true
break
}
var chunk chatStreamChunk
if err := json.Unmarshal(data, &chunk); err != nil {
send(ChatEvent{Err: fmt.Errorf("llm: decode stream chunk: %w", err)})
return
}
if chunk.Error != nil {
send(ChatEvent{Err: fmt.Errorf("llm: vllm error: %w", chunk.Error)})
return
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
if choice.Delta.ReasoningContent != "" {
if !send(ChatEvent{Thinking: choice.Delta.ReasoningContent}) {
return
}
}
if choice.Delta.Content != "" {
if !send(ChatEvent{Token: choice.Delta.Content}) {
return
}
}
if len(choice.Delta.ToolCalls) > 0 {
acc.add(choice.Delta.ToolCalls)
}
if choice.FinishReason != nil {
if acc.len() > 0 {
calls, err := acc.finish()
if err != nil {
send(ChatEvent{Err: fmt.Errorf("llm: %w: %w", errMalformedToolArgs, err)})
return
}
if !send(ChatEvent{ToolCalls: calls}) {
return
}
}
sawDone = true
send(ChatEvent{Done: true})
return
}
}
if err := scanner.Err(); err != nil {
send(ChatEvent{Err: fmt.Errorf("llm: reading stream: %w", err)})
return
}
// A [DONE] sentinel with no finish_reason chunk (or EOF with
// neither) means vLLM dropped the stream before completing the
// reply (crash, OOM-kill, network cut) — surface it rather
// than treating a truncated reply as a complete one.
if !sawDone && ctx.Err() == nil {
send(ChatEvent{Err: fmt.Errorf("llm: vllm closed the stream before finishing the reply")})
return
}
if sawDone {
send(ChatEvent{Done: true})
}
}()
return events, nil
}
func readErrorBody(resp *http.Response) string {
body := make([]byte, 2048)
n, _ := resp.Body.Read(body)
raw := body[:n]
var envelope struct {
Error wireError `json:"error"`
}
if err := json.Unmarshal(raw, &envelope); err == nil && envelope.Error.Message != "" {
return envelope.Error.Error()
}
return string(raw)
}
// ---------------------------------------------------------------------
// toolCallAccumulator: reassembles a streamed, index-keyed sequence of
// partial tool_calls deltas into complete calls. The OpenAI streaming
// contract sends a tool call's id/name once (on the chunk that first
// introduces that index) and its "arguments" JSON string in arbitrary-
// length fragments across however many subsequent chunks it takes to
// finish generating them — chunk boundaries do not align with JSON
// token boundaries at all, so nothing is parseable until finish().
// ---------------------------------------------------------------------
type toolCallAccumulator struct {
order []int
byIdx map[int]*accumulatingCall
}
type accumulatingCall struct {
id, name string
args strings.Builder
}
func newToolCallAccumulator() *toolCallAccumulator {
return &toolCallAccumulator{byIdx: map[int]*accumulatingCall{}}
}
func (a *toolCallAccumulator) len() int { return len(a.byIdx) }
func (a *toolCallAccumulator) add(deltas []wireToolCall) {
for _, d := range deltas {
idx := 0
if d.Index != nil {
idx = *d.Index
}
call, ok := a.byIdx[idx]
if !ok {
call = &accumulatingCall{}
a.byIdx[idx] = call
a.order = append(a.order, idx)
}
if d.ID != "" {
call.id = d.ID
}
if d.Function.Name != "" {
call.name = d.Function.Name
}
call.args.WriteString(d.Function.Arguments)
}
}
// finish parses every accumulated call's argument string as JSON and
// returns them in the order their indices first appeared. An empty
// arguments string (a tool with no parameters) is treated as "{}",
// matching what every vLLM tool-call parser actually emits for a
// no-arg call.
func (a *toolCallAccumulator) finish() ([]ToolCallRequest, error) {
sort.Ints(a.order)
out := make([]ToolCallRequest, 0, len(a.order))
for _, idx := range a.order {
call := a.byIdx[idx]
raw := strings.TrimSpace(call.args.String())
if raw == "" {
raw = "{}"
}
var args map[string]any
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return nil, fmt.Errorf("tool %q (id %s): %w", call.name, call.id, err)
}
out = append(out, ToolCallRequest{ID: call.id, Name: call.name, Arguments: args})
}
return out, nil
}
func toWireMessages(history []Message) []wireMessage {
out := make([]wireMessage, 0, len(history))
for _, m := range history {
content := m.Content
wm := wireMessage{
Role: string(m.Role),
Content: &content,
ToolCallID: m.ToolCallID,
Name: m.ToolName,
}
// An assistant message that only carries tool calls (no reply
// text) sends content as null, not "" — some vLLM tool-call
// parsers treat an empty-string content as "the model also
// said something" and choke on it.
if m.Role == RoleAssistant && len(m.ToolCalls) > 0 && m.Content == "" {
wm.Content = nil
}
for _, tc := range m.ToolCalls {
args := tc.Arguments
if args == nil {
args = map[string]any{}
}
argsJSON, err := json.Marshal(args)
if err != nil {
// Arguments is always built from values this process
// itself constructed (see toolcatalog/executor), so a
// marshal failure here would be a programming error,
// not a runtime condition — fall back to "{}" rather
// than drop the whole request.
argsJSON = []byte("{}")
}
wm.ToolCalls = append(wm.ToolCalls, wireToolCall{
ID: tc.ID,
Type: "function",
Function: wireToolCallFunction{Name: tc.Name, Arguments: string(argsJSON)},
})
}
out = append(out, wm)
}
return out
}
func toWireTools(tools []ToolDef) []wireToolDef {
out := make([]wireToolDef, 0, len(tools))
for _, t := range tools {
out = append(out, wireToolDef{
Type: "function",
Function: wireToolFunction{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
return out
}
// ---------------------------------------------------------------------
// Embeddings — OpenAI /v1/embeddings
// ---------------------------------------------------------------------
type embedRequest struct {
Model string `json:"model"`
Input string `json:"input"`
}
type embedResponse struct {
Data []struct {
Embedding []float32 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
}
// Embed returns the embedding vector for a single string, using the
// embed vLLM deployment's /v1/embeddings endpoint. Identical (model,
// text) pairs are served from an in-memory cache instead of
// round-tripping to vLLM again — see the embedCache doc comment on
// Client.
func (c *Client) Embed(ctx context.Context, text string) ([]float32, error) {
if c.embedCache != nil {
if v, ok := c.embedCache.get(c.EmbedModel, text); ok {
return v, nil
}
}
reqBody := embedRequest{Model: c.EmbedModel, Input: text}
buf, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("llm: marshal embed request: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
resp, err := c.doWithRetry(ctx, c.EmbedBaseURL+"/embeddings", buf, false)
if err != nil {
if looksTransient(err) {
return nil, fmt.Errorf("llm: embed request failed (is vLLM running at %s?): %w: %w", c.EmbedBaseURL, err, ErrUpstreamUnavailable)
}
return nil, fmt.Errorf("llm: embed request failed (is vLLM running at %s?): %w", c.EmbedBaseURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body := readErrorBody(resp)
if resp.StatusCode == http.StatusServiceUnavailable {
return nil, fmt.Errorf("llm: vllm embed returned %s: %s: %w", resp.Status, body, ErrUpstreamUnavailable)
}
return nil, fmt.Errorf("llm: vllm embed returned %s: %s", resp.Status, body)
}
var parsed embedResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return nil, fmt.Errorf("llm: decode embed response: %w", err)
}
if len(parsed.Data) == 0 {
return nil, fmt.Errorf("llm: embed response contained no vectors")
}
vec := parsed.Data[0].Embedding
if c.embedCache != nil {
c.embedCache.put(c.EmbedModel, text, vec)
}
return vec, nil
}
// ---------------------------------------------------------------------
// embedCache: small bounded in-memory cache for Embed() results
// ---------------------------------------------------------------------
type embedCache struct {
mu sync.Mutex
cap int
order []string // insertion order, for cheap FIFO eviction
values map[string][]float32
}
func newEmbedCache(capacity int) *embedCache {
return &embedCache{cap: capacity, values: make(map[string][]float32, capacity)}
}
func embedCacheKey(model, text string) string {
h := sha256.Sum256([]byte(model + "\x00" + text))
return hex.EncodeToString(h[:])
}
func (c *embedCache) get(model, text string) ([]float32, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.values[embedCacheKey(model, text)]
return v, ok
}
func (c *embedCache) put(model, text string, vec []float32) {
c.mu.Lock()
defer c.mu.Unlock()
key := embedCacheKey(model, text)
if _, exists := c.values[key]; !exists {
if len(c.order) >= c.cap {
// Evict the oldest entry (simple FIFO — good enough for a
// latency optimization, not a correctness-critical cache).
oldest := c.order[0]
c.order = c.order[1:]
delete(c.values, oldest)
}
c.order = append(c.order, key)
}
c.values[key] = vec
}

210
pkg/vllmkit/client_test.go Normal file
View File

@@ -0,0 +1,210 @@
package vllmkit
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestThinkWire(t *testing.T) {
cases := []struct {
name string
setting, override string
wantEnable *bool // nil means "chat_template_kwargs omitted entirely"
}{
{"both empty", "", "", nil},
{"setting only, bool true", "true", "", boolPtr(true)},
{"setting only, bool false", "false", "", boolPtr(false)},
{"setting only, level", "high", "", boolPtr(true)},
{"override wins over setting", "low", "high", boolPtr(true)},
{"override alone", "", "medium", boolPtr(true)},
{"override explicit false wins", "true", "false", boolPtr(false)},
{"case/space insensitive", " TRUE ", "", boolPtr(true)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := thinkWire(tc.setting, tc.override)
if tc.wantEnable == nil {
if got != nil {
t.Errorf("thinkWire(%q, %q) = %#v, want nil", tc.setting, tc.override, got)
}
return
}
gotEnable, ok := got["enable_thinking"].(bool)
if !ok || gotEnable != *tc.wantEnable {
t.Errorf("thinkWire(%q, %q) = %#v, want enable_thinking=%v", tc.setting, tc.override, got, *tc.wantEnable)
}
})
}
}
func boolPtr(b bool) *bool { return &b }
// TestChatStream_ThinkOverride confirms the per-call think parameter
// reaches the wire request's chat_template_kwargs.enable_thinking,
// taking precedence over the Client's configured default exactly as
// thinkWire specifies — this is the knob agentrun.Agent.ThinkPolicy
// relies on to vary reasoning on or off by round.
func TestChatStream_ThinkOverride(t *testing.T) {
var gotKwargs map[string]any
sawKwargs := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
gotKwargs = req.ChatTemplateKwargs
sawKwargs = req.ChatTemplateKwargs != nil
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintf(w, "data: %s\n\n", mustJSON(finishedChunk()))
fmt.Fprintf(w, "data: [DONE]\n\n")
}))
defer srv.Close()
run := func(clientThink, callThink string) (map[string]any, bool) {
c := New(srv.URL, "", "test-model", "")
c.Think = clientThink
events, err := c.ChatStream(context.Background(), []Message{{Role: RoleUser, Content: "hi"}}, nil, callThink)
if err != nil {
t.Fatalf("ChatStream: %v", err)
}
for ev := range events {
if ev.Err != nil {
t.Fatalf("stream error: %v", ev.Err)
}
}
return gotKwargs, sawKwargs
}
if got, saw := run("", ""); saw || got != nil {
t.Errorf("client default empty, no override: got kwargs=%#v saw=%v, want omitted", got, saw)
}
if got, _ := run("low", ""); got["enable_thinking"] != true {
t.Errorf("client default only: got kwargs=%#v, want enable_thinking=true", got)
}
if got, _ := run("false", "high"); got["enable_thinking"] != true {
t.Errorf("override should win: got kwargs=%#v, want enable_thinking=true", got)
}
if got, _ := run("true", "false"); got["enable_thinking"] != false {
t.Errorf("override alone (explicit false): got kwargs=%#v, want enable_thinking=false", got)
}
}
func finishedChunk() chatStreamChunk {
var c chatStreamChunk
c.Choices = make([]struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []wireToolCall `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
}, 1)
reason := "stop"
c.Choices[0].FinishReason = &reason
return c
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return string(b)
}
// TestChatStream_StreamsTokens is a sanity check that the SSE-based
// stream reader in ChatStream still behaves with an
// bufio.Scanner-friendly response (guards against accidentally
// breaking the "data: " framing while touching this file).
func TestChatStream_StreamsTokens(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
bw := bufio.NewWriter(w)
write := func(content string, done bool) {
var c chatStreamChunk
c.Choices = make([]struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []wireToolCall `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
}, 1)
c.Choices[0].Delta.Content = content
if done {
reason := "stop"
c.Choices[0].FinishReason = &reason
}
fmt.Fprintf(bw, "data: %s\n\n", mustJSON(c))
}
write("hel", false)
write("lo", true)
fmt.Fprint(bw, "data: [DONE]\n\n")
bw.Flush()
}))
defer srv.Close()
c := New(srv.URL, "", "test-model", "")
events, err := c.ChatStream(context.Background(), []Message{{Role: RoleUser, Content: "hi"}}, nil, "")
if err != nil {
t.Fatalf("ChatStream: %v", err)
}
var got string
for ev := range events {
if ev.Err != nil {
t.Fatalf("stream error: %v", ev.Err)
}
got += ev.Token
}
if got != "hello" {
t.Errorf("got tokens %q, want %q", got, "hello")
}
}
// TestToolCallAccumulator_Streamed confirms incremental, index-keyed
// tool_calls deltas (arguments split across several chunks, as a real
// vLLM server streams them) reassemble into one complete call.
func TestToolCallAccumulator_Streamed(t *testing.T) {
idx := 0
acc := newToolCallAccumulator()
acc.add([]wireToolCall{{Index: &idx, ID: "call_1", Function: wireToolCallFunction{Name: "grep"}}})
acc.add([]wireToolCall{{Index: &idx, Function: wireToolCallFunction{Arguments: `{"pat`}}})
acc.add([]wireToolCall{{Index: &idx, Function: wireToolCallFunction{Arguments: `tern":"foo"}`}}})
calls, err := acc.finish()
if err != nil {
t.Fatalf("finish: %v", err)
}
if len(calls) != 1 {
t.Fatalf("got %d calls, want 1", len(calls))
}
if calls[0].ID != "call_1" || calls[0].Name != "grep" {
t.Errorf("got %+v, want id=call_1 name=grep", calls[0])
}
if calls[0].Arguments["pattern"] != "foo" {
t.Errorf("got arguments %#v, want pattern=foo", calls[0].Arguments)
}
}
func TestIsToolCallParseError(t *testing.T) {
if !IsToolCallParseError(fmt.Errorf("wrap: %w", errMalformedToolArgs)) {
t.Error("wrapped errMalformedToolArgs should be detected")
}
if !IsToolCallParseError(fmt.Errorf("llm: vllm error: BadRequestError: Failed to parse tool calls from model output")) {
t.Error("server-side tool-call parse failure should be detected")
}
if IsToolCallParseError(fmt.Errorf("llm: vllm returned 500 Internal Server Error: out of memory")) {
t.Error("unrelated server error should not be classified as a tool-call parse error")
}
if IsToolCallParseError(nil) {
t.Error("nil error should never be a tool-call parse error")
}
}

73
pkg/vllmkit/types.go Normal file
View File

@@ -0,0 +1,73 @@
package vllmkit
// Role, Message, ToolCallRequest and ToolDef are local equivalents of
// the monolith's internal/models types — kept here, scoped to exactly
// what the OpenAI-compatible chat-completions wire protocol vLLM
// serves needs, so this package has no dependency beyond the Go
// standard library. The agent service (the only caller of ChatStream)
// translates its own domain messages to and from these at the
// boundary; see services/agent/internal/infrastructure/agentrun.
type Role string
const (
RoleUser Role = "user"
RoleAssistant Role = "assistant"
RoleSystem Role = "system"
RoleTool Role = "tool"
)
// Message is one turn in a conversation, as sent to/received from
// vLLM's OpenAI-compatible /v1/chat/completions endpoint.
type Message struct {
Role Role
Content string
ToolName string
Thinking string // reasoning trace (OpenAI wire: "reasoning_content"); sent back to the model, never persisted
// ToolCalls is set on an assistant message that requested one or
// more tool invocations — see ToolCallRequest.
ToolCalls []ToolCallRequest
// ToolCallID identifies, on a role="tool" message, which of the
// preceding assistant message's ToolCalls this is the result for.
// The OpenAI tool-calling contract requires every tool message to
// carry the matching call's id (unlike Ollama's native /api/chat,
// which correlated tool replies positionally with no id at all) —
// vLLM's tool-call parsers (hermes, llama3-json, qwen, …) reject a
// tool message with a missing/mismatched tool_call_id outright, so
// this must be populated whenever Role == RoleTool. Callers get it
// for free: ToolCallRequest.ID below is populated straight off the
// id vLLM assigned the call, and ToolResult carries it forward
// unchanged — see agentrun.Agent.Run, which copies
// ToolCallRequest.ID into the ToolResult it builds and then into
// this field when it appends the reply message.
ToolCallID string
}
// ToolCallRequest is a single tool invocation the model asked for. ID
// is the id vLLM's response assigned the call (OpenAI wire:
// tool_calls[].id) — callers must echo it back as ToolResult's
// ToolCallID so the follow-up tool message can carry Message.ToolCallID.
type ToolCallRequest struct {
ID string
Name string
Arguments map[string]any
}
// ToolResult is what gets fed back to the model after executing a
// ToolCallRequest.
type ToolResult struct {
ToolCallID string
Name string
Content string
IsError bool
}
// ToolDef describes a single callable tool in the JSON-schema shape
// the OpenAI-compatible function-calling API (and therefore vLLM)
// expects.
type ToolDef struct {
Name string
Description string
Parameters map[string]any // JSON Schema object
}