165 lines
4.7 KiB
Go
165 lines
4.7 KiB
Go
package llm
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"agent-native/runtime/argschema"
|
|
)
|
|
|
|
// Completion bases recorded on a successful OpenAI-chat message_end.
|
|
const (
|
|
CompletionDoneMarker = "done_marker"
|
|
CompletionRouteContractEOF = "route_contract_eof"
|
|
)
|
|
|
|
// RouteContractEOF lets one exact OpenAI-chat route release tool calls from a
|
|
// stream that ended without [DONE], when the HTTP body boundary and every
|
|
// frame prove the calls complete. The zero value is off. IdentitySHA256 must
|
|
// equal RouteContractIdentity(Model, BaseURL), and BaseURL must already be in
|
|
// NormalizeRouteBaseURL form.
|
|
type RouteContractEOF struct {
|
|
Model string
|
|
BaseURL string
|
|
IdentitySHA256 string
|
|
}
|
|
|
|
// RouteContractIdentity is the lowercase hex sha256 of
|
|
// "openai-chat\n" + model + "\n" + baseURL.
|
|
func RouteContractIdentity(model, baseURL string) string {
|
|
sum := sha256.Sum256([]byte("openai-chat\n" + model + "\n" + baseURL))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// NormalizeRouteBaseURL returns the https base URL with lowercase scheme and
|
|
// host and no trailing '/'. Any other scheme, userinfo, query or fragment is
|
|
// not a match.
|
|
func NormalizeRouteBaseURL(raw string) (string, bool) {
|
|
if strings.ContainsAny(raw, "?#") {
|
|
return "", false
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || !strings.EqualFold(u.Scheme, "https") || u.Opaque != "" ||
|
|
u.User != nil || u.Host == "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
|
|
return "", false
|
|
}
|
|
return "https://" + strings.ToLower(u.Host) + strings.TrimRight(u.EscapedPath(), "/"), true
|
|
}
|
|
|
|
func (rc *RouteContractEOF) off() bool {
|
|
return rc == nil || *rc == RouteContractEOF{}
|
|
}
|
|
|
|
// admits is condition C1 apart from the redirect check.
|
|
func (rc *RouteContractEOF) admits(protocol Protocol, model, base string) bool {
|
|
if protocol != "" && protocol != ProtocolOpenAIChat {
|
|
return false
|
|
}
|
|
if model != rc.Model {
|
|
return false
|
|
}
|
|
normalized, ok := NormalizeRouteBaseURL(base)
|
|
if !ok || normalized != rc.BaseURL {
|
|
return false
|
|
}
|
|
return RouteContractIdentity(rc.Model, rc.BaseURL) == rc.IdentitySHA256
|
|
}
|
|
|
|
// routeContract evaluates a configured RouteContractEOF for one response.
|
|
type routeContract struct {
|
|
active bool
|
|
tools []ToolDecl
|
|
}
|
|
|
|
// newRouteContract returns nil when the client has no contract configured.
|
|
func (c *Client) newRouteContract(model, base string, req *http.Request, resp *http.Response, tools []ToolDecl) *routeContract {
|
|
if c.RouteContract.off() {
|
|
return nil
|
|
}
|
|
return &routeContract{
|
|
active: c.RouteContract.admits(c.Protocol, model, base) && sameTarget(req, resp),
|
|
tools: tools,
|
|
}
|
|
}
|
|
|
|
// sameTarget rejects any redirect that changed scheme, host, port or path.
|
|
func sameTarget(req *http.Request, resp *http.Response) bool {
|
|
if req == nil || req.URL == nil || resp == nil || resp.Request == nil || resp.Request.URL == nil {
|
|
return false
|
|
}
|
|
sent, final := req.URL, resp.Request.URL
|
|
return strings.EqualFold(sent.Scheme, final.Scheme) &&
|
|
strings.EqualFold(sent.Hostname(), final.Hostname()) &&
|
|
sent.Port() == final.Port() &&
|
|
sent.EscapedPath() == final.EscapedPath()
|
|
}
|
|
|
|
// check returns the first failing condition id, or "" when all hold.
|
|
func (rc *routeContract) check(boundary *BodyBoundaryObservation, obs *TerminalObservation, facts sseFacts, calls []ToolCall) string {
|
|
switch {
|
|
case !rc.active:
|
|
return "C1"
|
|
case !boundaryProven(boundary):
|
|
return "C2"
|
|
case obs == nil || obs.ReadOutcome != "clean_eof":
|
|
return "C3"
|
|
case facts.finishChunks != 1 || facts.finishReason != "tool_calls" || facts.multiChoice:
|
|
return "C4"
|
|
case !obs.UsagePresent:
|
|
return "C5"
|
|
case facts.fragmentAfterFinish:
|
|
return "C6"
|
|
case !callsComplete(calls):
|
|
return "C7"
|
|
case !callsDeclared(calls, rc.tools):
|
|
return "C8"
|
|
case obs.TailEventKind != "json_frame" || !obs.LastFrameJSONComplete:
|
|
return "C9"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func boundaryProven(b *BodyBoundaryObservation) bool {
|
|
if b == nil {
|
|
return false
|
|
}
|
|
return (b.FramingClass == "http2_stream" && b.EOFSeen) ||
|
|
(b.FramingClass == "known_length" && b.LengthRelation == "match")
|
|
}
|
|
|
|
func callsComplete(calls []ToolCall) bool {
|
|
if len(calls) == 0 {
|
|
return false
|
|
}
|
|
for _, call := range calls {
|
|
var args map[string]json.RawMessage
|
|
if call.ID == "" || call.Name == "" {
|
|
return false
|
|
}
|
|
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil || args == nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func callsDeclared(calls []ToolCall, tools []ToolDecl) bool {
|
|
for _, call := range calls {
|
|
idx := -1
|
|
for i, decl := range tools {
|
|
if decl.Name == call.Name {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 || argschema.Validate(tools[idx].Parameters, json.RawMessage(call.Arguments)) != nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|