mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 20:19:55 +00:00
* [agent-network] Shared proto, OpenAPI schema, and generated types * [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API Adds the account-scoped agent-network module: provider/policy/budget CRUD and store, the reverse-proxy service synthesizer, policy selection + limit enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries), and the management HTTP + proxy gRPC surfaces. * [management] Fix agent-network proxy-peer fan-out on affected-peer recompute The affected-peers resolver loaded only persisted reverse-proxy services, but agent-network services are synthesized on demand and never persisted. As a result the embedded proxy peer was never folded into the affected set when a client's group changed, so the proxy received no network-map update for a newly authorised client and rejected its handshake until a full resync (restart). loadProxyServices now merges the synthesized agent-network services (injected via a registration hook to avoid an import cycle), so proxy peers learn newly authorised clients immediately. * [proxy] Reverse-proxy middleware framework, chain, and request plumbing The per-target middleware chain (slots, dispatcher, mutation gate, metadata merger), body capture, access-log terminal sink, and the proxy wiring that builds + runs chains for synthesized agent-network services. * [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock) Request/response parsers and SSE/event-stream metering, the embedded pricing table, and the builtin middleware set: request parser, router, policy limit-check/record, cost meter, guardrail, identity inject, response parser. Includes the path-routed providers — Google Vertex AI (keyfile:: service-account OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional /bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny. * [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners * [agent-network] End-to-end test suite, module docs, and deployment preset * [agent-network] Fix codespell typos and exclude false positives - labelgen word pool: vermillion -> vermilion, racoon -> raccoon. - codespell ignore list: add flate (Go compress/flate package), recordin (a test-local identifier), and unparseable (a valid alternative spelling used consistently across identifiers + a metadata-value constant). * [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode) The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both engines. * [agent-network] Remove e2e shell-script suite from this branch The end-to-end shell scripts under scripts/e2e/ are maintained in a separate testing suite and are not part of this change set. * [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams Strip PR-review framing, commit references, absolute paths, and stale internal references from the agent-network module docs; fix broken relative links; verify all diagrams against the current architecture. Remove the internal AI-reviewer prompt file. * [management] Refine session expiration handling to support 3-state encoding for SSO deadlines * [agent-network] Relocate agentnetwork package to internals/modules Move management/server/agentnetwork (and its catalog/, labelgen/, types/ subpackages) to management/internals/modules/agentnetwork, alongside the reverse-proxy module, and rewrite all importers. Pure relocation: package names, the synthesizer + affectedpeers registration hook, and store access (shared store.Store) are unchanged, so no import cycle is introduced (affectedpeers still depends only on the agentnetwork/types leaf). * [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints) Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into the module at internals/modules/agentnetwork/handlers (package handlers) and rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the reverse-proxy module convention. Wiring in http/handler.go updated accordingly.
413 lines
13 KiB
Go
413 lines
13 KiB
Go
package llm
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// OpenAIParser implements the Parser interface for OpenAI-compatible APIs.
|
|
// It recognizes chat.completions, completions, embeddings, and the newer
|
|
// responses endpoint; any proxy path-prefix stripping is tolerated by the
|
|
// substring match in DetectFromURL.
|
|
type OpenAIParser struct{}
|
|
|
|
// openAIPathHints are substring patterns that mark a request as
|
|
// OpenAI-shaped. The bare `/chat/completions` is listed alongside
|
|
// `/v1/chat/completions` because gateways like Cloudflare AI
|
|
// Gateway place their own version segment before the provider
|
|
// slug (gateway/v1/{account}/{gateway}/openai/chat/completions) —
|
|
// the canonical `/v1/` ends up nowhere near `/chat/completions`,
|
|
// so the `/v1/chat/completions` hint misses. `/chat/completions`
|
|
// is OpenAI's API contract: any service accepting OpenAI bodies
|
|
// serves at this path, so false-positive risk is negligible.
|
|
// `/completions` (legacy), `/embeddings`, and `/responses` are
|
|
// kept on the canonical-only path because their bare forms are
|
|
// too generic to be safe substrings.
|
|
var openAIPathHints = []string{
|
|
"/v1/chat/completions",
|
|
"/v1/completions",
|
|
"/v1/embeddings",
|
|
"/v1/responses",
|
|
"/chat/completions",
|
|
}
|
|
|
|
// Provider returns ProviderOpenAI.
|
|
func (OpenAIParser) Provider() Provider { return ProviderOpenAI }
|
|
|
|
// ProviderName returns the stable label used for metrics and metadata.
|
|
func (OpenAIParser) ProviderName() string { return "openai" }
|
|
|
|
// DetectFromURL reports whether the given request path looks like an OpenAI
|
|
// API endpoint. The match is case-insensitive and substring-based so that a
|
|
// reverse proxy prefix strip or rewrite does not defeat detection.
|
|
func (OpenAIParser) DetectFromURL(path string) bool {
|
|
lower := strings.ToLower(path)
|
|
for _, hint := range openAIPathHints {
|
|
if strings.Contains(lower, hint) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type openAIRequest struct {
|
|
Model string `json:"model"`
|
|
Stream *bool `json:"stream"`
|
|
StreamOptions *struct {
|
|
IncludeUsage *bool `json:"include_usage"`
|
|
} `json:"stream_options"`
|
|
// Chat Completions / Completions: messages[].content (string or array of
|
|
// content parts). Responses API: input is either a string or an array of
|
|
// items with content parts. We use json.RawMessage to defer parsing each
|
|
// shape independently.
|
|
Messages []openAIMessage `json:"messages"`
|
|
Prompt json.RawMessage `json:"prompt"`
|
|
Input json.RawMessage `json:"input"`
|
|
}
|
|
|
|
type openAIMessage struct {
|
|
Role string `json:"role"`
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
|
|
// ParseRequest extracts the model name and streaming flag from an OpenAI
|
|
// request body. Unknown or missing fields leave the corresponding struct
|
|
// members zero-valued.
|
|
func (OpenAIParser) ParseRequest(body []byte) (RequestFacts, error) {
|
|
var req openAIRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return RequestFacts{}, fmt.Errorf("decode openai request: %w: %v", ErrMalformedRequest, err)
|
|
}
|
|
return RequestFacts{
|
|
Model: req.Model,
|
|
Stream: ptrDeref(req.Stream),
|
|
}, nil
|
|
}
|
|
|
|
// openAIResponse accepts both naming conventions in a single struct because
|
|
// OpenAI's older Chat Completions API uses prompt_tokens/completion_tokens
|
|
// while the newer Responses API (/v1/responses) uses input_tokens/output_tokens
|
|
// (aligned with Anthropic). Pointer fields let us tell "absent" from "zero".
|
|
//
|
|
// PromptTokensDetails.CachedTokens (Chat Completions) and
|
|
// InputTokensDetails.CachedTokens (Responses API) carry the SUBSET of
|
|
// prompt/input tokens that hit the prompt cache. Cost-meter applies the
|
|
// discount rate to that subset and the regular rate to the remainder so
|
|
// we never double-bill the cached portion.
|
|
type openAIResponse struct {
|
|
Usage struct {
|
|
PromptTokens *int64 `json:"prompt_tokens"`
|
|
CompletionTokens *int64 `json:"completion_tokens"`
|
|
InputTokens *int64 `json:"input_tokens"`
|
|
OutputTokens *int64 `json:"output_tokens"`
|
|
TotalTokens *int64 `json:"total_tokens"`
|
|
PromptTokensDetails *struct {
|
|
CachedTokens *int64 `json:"cached_tokens"`
|
|
} `json:"prompt_tokens_details"`
|
|
InputTokensDetails *struct {
|
|
CachedTokens *int64 `json:"cached_tokens"`
|
|
} `json:"input_tokens_details"`
|
|
} `json:"usage"`
|
|
}
|
|
|
|
// ParseResponse decodes the non-streaming OpenAI response envelope. Status
|
|
// codes other than 200 are treated as non-LLM responses so the caller can
|
|
// skip cost accounting without aborting the request.
|
|
func (OpenAIParser) ParseResponse(status int, contentType string, body []byte) (Usage, error) {
|
|
if status != 200 {
|
|
return Usage{}, fmt.Errorf("openai status %d: %w", status, ErrNotLLMResponse)
|
|
}
|
|
if isEventStream(contentType) {
|
|
return Usage{}, ErrStreamingUnsupported
|
|
}
|
|
if !isJSON(contentType) {
|
|
return Usage{}, fmt.Errorf("openai content-type %q: %w", contentType, ErrNotLLMResponse)
|
|
}
|
|
|
|
var resp openAIResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return Usage{}, fmt.Errorf("decode openai response: %w: %v", ErrMalformedResponse, err)
|
|
}
|
|
|
|
// Responses-API names take precedence when present; fall back to the older
|
|
// Chat Completions names. This handles both endpoints transparently
|
|
// without forcing a per-route configuration.
|
|
u := Usage{
|
|
InputTokens: pickInt64(resp.Usage.InputTokens, resp.Usage.PromptTokens),
|
|
OutputTokens: pickInt64(resp.Usage.OutputTokens, resp.Usage.CompletionTokens),
|
|
TotalTokens: derefInt64(resp.Usage.TotalTokens),
|
|
CachedInputTokens: openAICachedTokens(resp),
|
|
}
|
|
if u.TotalTokens == 0 && (u.InputTokens > 0 || u.OutputTokens > 0) {
|
|
u.TotalTokens = u.InputTokens + u.OutputTokens
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// openAICachedTokens returns the cached-prompt subset reported by
|
|
// either the Responses-API (input_tokens_details.cached_tokens) or
|
|
// the Chat-Completions API (prompt_tokens_details.cached_tokens).
|
|
// Responses-API takes precedence when both are populated.
|
|
func openAICachedTokens(resp openAIResponse) int64 {
|
|
// Responses-API details are authoritative when present: an explicit
|
|
// cached_tokens of 0 must be honored, not treated as missing and
|
|
// overridden by the Chat-Completions field (which would overstate cache).
|
|
if resp.Usage.InputTokensDetails != nil && resp.Usage.InputTokensDetails.CachedTokens != nil {
|
|
return derefInt64(resp.Usage.InputTokensDetails.CachedTokens)
|
|
}
|
|
if resp.Usage.PromptTokensDetails != nil {
|
|
return derefInt64(resp.Usage.PromptTokensDetails.CachedTokens)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ExtractPrompt returns the user-visible prompt text from an OpenAI request.
|
|
// Handles chat.completions (messages[].content), legacy completions (prompt
|
|
// string), and the Responses API (input as string or content-part array).
|
|
// Returns "" when nothing extractable is found.
|
|
func (OpenAIParser) ExtractPrompt(body []byte) string {
|
|
var req openAIRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return ""
|
|
}
|
|
if len(req.Messages) > 0 {
|
|
return joinMessages(req.Messages)
|
|
}
|
|
if len(req.Input) > 0 {
|
|
return extractResponsesInput(req.Input)
|
|
}
|
|
if len(req.Prompt) > 0 {
|
|
return decodeStringOrJoin(req.Prompt)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// extractResponsesInput handles the Responses API `input` field. It is one
|
|
// of three shapes: a plain string, an array of message items
|
|
// ({role, content: string | [parts]}) as sent by Codex and the Responses
|
|
// SDK, or a flat array of content parts ({type, text/input_text}). Message
|
|
// items are flattened to "role: text" lines; items without extractable text
|
|
// (reasoning blocks, tool calls) are skipped.
|
|
func extractResponsesInput(raw json.RawMessage) string {
|
|
if s, ok := tryDecodeString(raw); ok {
|
|
return s
|
|
}
|
|
var items []struct {
|
|
Role string `json:"role"`
|
|
Content json.RawMessage `json:"content"`
|
|
Text string `json:"text"`
|
|
InputText string `json:"input_text"`
|
|
}
|
|
if err := json.Unmarshal(raw, &items); err != nil {
|
|
return extractContentParts(raw)
|
|
}
|
|
var b strings.Builder
|
|
for _, it := range items {
|
|
var text string
|
|
switch {
|
|
case len(it.Content) > 0:
|
|
text = decodeStringOrJoin(it.Content)
|
|
case it.Text != "":
|
|
text = it.Text
|
|
case it.InputText != "":
|
|
text = it.InputText
|
|
}
|
|
if text == "" {
|
|
continue
|
|
}
|
|
if b.Len() > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
if it.Role != "" {
|
|
b.WriteString(it.Role)
|
|
b.WriteString(": ")
|
|
}
|
|
b.WriteString(text)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ExtractSessionID reads the OpenAI session marker. Codex (the Responses
|
|
// API client) stamps client_metadata.session_id on every request body;
|
|
// plain chat.completions traffic carries no session id and yields "".
|
|
func (OpenAIParser) ExtractSessionID(body []byte) string {
|
|
var req struct {
|
|
ClientMetadata struct {
|
|
SessionID string `json:"session_id"`
|
|
} `json:"client_metadata"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return ""
|
|
}
|
|
return req.ClientMetadata.SessionID
|
|
}
|
|
|
|
type openAIChatChoice struct {
|
|
Message struct {
|
|
Role string `json:"role"`
|
|
Content json.RawMessage `json:"content"`
|
|
} `json:"message"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type openAIChatResponse struct {
|
|
Choices []openAIChatChoice `json:"choices"`
|
|
// Responses API: output[].content[].text
|
|
Output []struct {
|
|
Type string `json:"type"`
|
|
Content json.RawMessage `json:"content"`
|
|
Text string `json:"text"`
|
|
} `json:"output"`
|
|
OutputText string `json:"output_text"`
|
|
}
|
|
|
|
// ExtractCompletion returns the assistant text from a non-streaming OpenAI
|
|
// response. Handles chat.completions (choices[].message.content), legacy
|
|
// completions (choices[].text), and Responses API (output[].content[].text
|
|
// or the convenience output_text field).
|
|
func (OpenAIParser) ExtractCompletion(status int, contentType string, body []byte) string {
|
|
if status != 200 || isEventStream(contentType) || !isJSON(contentType) {
|
|
return ""
|
|
}
|
|
var resp openAIChatResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return ""
|
|
}
|
|
if resp.OutputText != "" {
|
|
return resp.OutputText
|
|
}
|
|
for _, c := range resp.Choices {
|
|
if len(c.Message.Content) > 0 {
|
|
if s := decodeStringOrJoin(c.Message.Content); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
if c.Text != "" {
|
|
return c.Text
|
|
}
|
|
}
|
|
for _, o := range resp.Output {
|
|
if o.Text != "" {
|
|
return o.Text
|
|
}
|
|
if len(o.Content) > 0 {
|
|
if s := extractContentParts(o.Content); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// joinMessages flattens a chat.completions messages array into a single
|
|
// "role: content" string per message, separated by newlines. Roles surface
|
|
// system/user/assistant context which is useful for log review.
|
|
func joinMessages(msgs []openAIMessage) string {
|
|
var b strings.Builder
|
|
for i, m := range msgs {
|
|
if i > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
if m.Role != "" {
|
|
b.WriteString(m.Role)
|
|
b.WriteString(": ")
|
|
}
|
|
b.WriteString(decodeStringOrJoin(m.Content))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// extractContentParts handles the Responses-API content shape, which is
|
|
// either a single string or an array of {type, text} parts. text and
|
|
// input_text both carry user-facing content.
|
|
func extractContentParts(raw json.RawMessage) string {
|
|
if s, ok := tryDecodeString(raw); ok {
|
|
return s
|
|
}
|
|
var parts []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
InputText string `json:"input_text"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parts); err != nil {
|
|
// Last-ditch: array of strings.
|
|
var arr []string
|
|
if json.Unmarshal(raw, &arr) == nil {
|
|
return strings.Join(arr, "\n")
|
|
}
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
for _, p := range parts {
|
|
var text string
|
|
switch {
|
|
case p.Text != "":
|
|
text = p.Text
|
|
case p.InputText != "":
|
|
text = p.InputText
|
|
}
|
|
if text == "" {
|
|
continue
|
|
}
|
|
if b.Len() > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
b.WriteString(text)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// decodeStringOrJoin accepts either a JSON string or a content-parts array
|
|
// (chat.completions multimodal) and returns a flat string. Multimodal parts
|
|
// are separated by newlines; non-text parts are skipped.
|
|
func decodeStringOrJoin(raw json.RawMessage) string {
|
|
if s, ok := tryDecodeString(raw); ok {
|
|
return s
|
|
}
|
|
return extractContentParts(raw)
|
|
}
|
|
|
|
func tryDecodeString(raw json.RawMessage) (string, bool) {
|
|
if len(raw) == 0 {
|
|
return "", false
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
return s, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// pickInt64 returns the first non-nil pointer's value. Used to prefer one
|
|
// naming convention while transparently falling back to another.
|
|
func pickInt64(preferred, fallback *int64) int64 {
|
|
if preferred != nil {
|
|
return *preferred
|
|
}
|
|
return derefInt64(fallback)
|
|
}
|
|
|
|
func derefInt64(v *int64) int64 {
|
|
if v == nil {
|
|
return 0
|
|
}
|
|
return *v
|
|
}
|
|
|
|
func ptrDeref(b *bool) bool {
|
|
if b == nil {
|
|
return false
|
|
}
|
|
return *b
|
|
}
|
|
|
|
func isEventStream(contentType string) bool {
|
|
return strings.Contains(strings.ToLower(contentType), "text/event-stream")
|
|
}
|
|
|
|
func isJSON(contentType string) bool {
|
|
lower := strings.ToLower(contentType)
|
|
return strings.Contains(lower, "application/json") || strings.Contains(lower, "+json")
|
|
}
|