216 lines
6.9 KiB
Go
216 lines
6.9 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
iop "iop/proto/gen/iop"
|
|
"strings"
|
|
)
|
|
|
|
// providerAssembledObservation is the Edge-internal human-readable view of a
|
|
// provider Chat Completions/Responses body assembled by providerChatAssembler.
|
|
// It feeds Edge-local logging and usage metrics only and is never written to
|
|
// the caller response; pure passthrough relays provider bytes unmodified.
|
|
type providerAssembledObservation struct {
|
|
Content string `json:"content,omitempty"`
|
|
Reasoning string `json:"reasoning,omitempty"`
|
|
ToolCallNames []string `json:"tool_call_names,omitempty"`
|
|
BodyBytes int `json:"body_bytes"`
|
|
}
|
|
|
|
// providerChatAssembler accumulates a human-readable view of the provider
|
|
// Chat Completions response for the assembled observation. It
|
|
// tolerates non-JSON and partial payloads: whatever cannot be parsed simply
|
|
// yields an empty summary.
|
|
type providerChatAssembler struct {
|
|
streaming bool
|
|
nonStreamingParsed bool
|
|
bodyBytes int
|
|
pending []byte
|
|
content strings.Builder
|
|
reasoning strings.Builder
|
|
toolCallNames []string
|
|
// usage holds provider-reported token counts observed in the passthrough
|
|
// body. It feeds Edge-internal metrics only.
|
|
usage usageObservation
|
|
}
|
|
|
|
// providerChatDeltaEnvelope matches the provider fields the assembler
|
|
// observes; streaming uses delta, non-streaming uses message.
|
|
type providerChatDeltaEnvelope struct {
|
|
Content string `json:"content"`
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
Reasoning string `json:"reasoning"`
|
|
ToolCalls []struct {
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
} `json:"function"`
|
|
} `json:"tool_calls"`
|
|
}
|
|
|
|
// providerUsageEnvelope matches the OpenAI-compatible usage object from
|
|
// both Chat Completions and Responses. Chat Completions uses prompt_tokens/
|
|
// completion_tokens; Responses uses input_tokens/output_tokens. The optional
|
|
// detail objects carry reasoning and cached-input tokens for both formats.
|
|
type providerUsageEnvelope struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
PromptTokensDetails *struct {
|
|
CachedTokens int `json:"cached_tokens"`
|
|
} `json:"prompt_tokens_details"`
|
|
CompletionTokensDetails *struct {
|
|
ReasoningTokens int `json:"reasoning_tokens"`
|
|
} `json:"completion_tokens_details"`
|
|
InputTokensDetails *struct {
|
|
CachedTokens int `json:"cached_tokens"`
|
|
} `json:"input_tokens_details"`
|
|
OutputTokensDetails *struct {
|
|
ReasoningTokens int `json:"reasoning_tokens"`
|
|
} `json:"output_tokens_details"`
|
|
}
|
|
|
|
// recordUsage stores the latest provider-reported usage. The provider tunnel
|
|
// body is never mutated; this observation feeds Edge-internal metrics only
|
|
// (SDD S05/D04). Chat Completions keys (prompt_tokens/completion_tokens) take
|
|
// precedence; if absent, Responses keys (input_tokens/output_tokens) are used.
|
|
func (a *providerChatAssembler) recordUsage(u *providerUsageEnvelope) {
|
|
if u == nil {
|
|
return
|
|
}
|
|
// Prefer Chat Completions keys; fall back to Responses keys.
|
|
if u.PromptTokens != 0 {
|
|
a.usage.inputTokens = u.PromptTokens
|
|
a.usage.outputTokens = u.CompletionTokens
|
|
if d := u.PromptTokensDetails; d != nil {
|
|
a.usage.cachedInputTokens = d.CachedTokens
|
|
}
|
|
if d := u.CompletionTokensDetails; d != nil {
|
|
a.usage.reasoningTokens = d.ReasoningTokens
|
|
}
|
|
return
|
|
}
|
|
if u.InputTokens != 0 {
|
|
a.usage.inputTokens = u.InputTokens
|
|
a.usage.outputTokens = u.OutputTokens
|
|
if d := u.InputTokensDetails; d != nil {
|
|
a.usage.cachedInputTokens = d.CachedTokens
|
|
}
|
|
if d := u.OutputTokensDetails; d != nil {
|
|
a.usage.reasoningTokens = d.ReasoningTokens
|
|
}
|
|
}
|
|
}
|
|
|
|
// recordProtoUsage stores usage carried on a provider tunnel USAGE frame. Like
|
|
// recordUsage it observes for metrics only and never alters the response body.
|
|
func (a *providerChatAssembler) recordProtoUsage(u *iop.Usage) {
|
|
if u == nil {
|
|
return
|
|
}
|
|
a.usage.inputTokens = int(u.GetInputTokens())
|
|
a.usage.outputTokens = int(u.GetOutputTokens())
|
|
a.usage.reasoningTokens = int(u.GetReasoningTokens())
|
|
a.usage.cachedInputTokens = int(u.GetCachedInputTokens())
|
|
}
|
|
|
|
// usageObservation returns the observed token usage plus the observed reasoning
|
|
// character count used by auxiliary and estimated-token reasoning metrics.
|
|
func (a *providerChatAssembler) usageObservation() usageObservation {
|
|
obs := a.usage
|
|
obs.reasoningChars = a.reasoning.Len()
|
|
return obs
|
|
}
|
|
|
|
func (a *providerChatAssembler) Write(chunk []byte) {
|
|
a.bodyBytes += len(chunk)
|
|
a.pending = append(a.pending, chunk...)
|
|
if !a.streaming {
|
|
return
|
|
}
|
|
for {
|
|
idx := bytes.IndexByte(a.pending, '\n')
|
|
if idx < 0 {
|
|
return
|
|
}
|
|
line := strings.TrimRight(string(a.pending[:idx]), "\r")
|
|
a.pending = a.pending[idx+1:]
|
|
a.consumeSSELine(line)
|
|
}
|
|
}
|
|
|
|
func (a *providerChatAssembler) consumeSSELine(line string) {
|
|
payload, ok := strings.CutPrefix(line, "data:")
|
|
if !ok {
|
|
return
|
|
}
|
|
payload = strings.TrimSpace(payload)
|
|
if payload == "" || payload == "[DONE]" {
|
|
return
|
|
}
|
|
var chunk struct {
|
|
Choices []struct {
|
|
Delta providerChatDeltaEnvelope `json:"delta"`
|
|
} `json:"choices"`
|
|
Usage *providerUsageEnvelope `json:"usage"`
|
|
}
|
|
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
|
return
|
|
}
|
|
for _, choice := range chunk.Choices {
|
|
a.consumeDelta(choice.Delta)
|
|
}
|
|
a.recordUsage(chunk.Usage)
|
|
|
|
// Responses streaming: nested response.usage from events like
|
|
// response.completed. This captures provider-reported token usage from
|
|
// the Responses API SSE payload (SDD S05/D04).
|
|
if a.usage.inputTokens == 0 && a.usage.outputTokens == 0 {
|
|
var respEvent struct {
|
|
Response struct {
|
|
Usage *providerUsageEnvelope `json:"usage"`
|
|
} `json:"response"`
|
|
}
|
|
if err := json.Unmarshal([]byte(payload), &respEvent); err == nil {
|
|
if respEvent.Response.Usage != nil {
|
|
a.recordUsage(respEvent.Response.Usage)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *providerChatAssembler) consumeDelta(delta providerChatDeltaEnvelope) {
|
|
a.content.WriteString(delta.Content)
|
|
a.reasoning.WriteString(delta.ReasoningContent)
|
|
a.reasoning.WriteString(delta.Reasoning)
|
|
for _, call := range delta.ToolCalls {
|
|
if name := strings.TrimSpace(call.Function.Name); name != "" {
|
|
a.toolCallNames = append(a.toolCallNames, name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *providerChatAssembler) observation() *providerAssembledObservation {
|
|
if !a.streaming && !a.nonStreamingParsed {
|
|
a.nonStreamingParsed = true
|
|
var resp struct {
|
|
Choices []struct {
|
|
Message providerChatDeltaEnvelope `json:"message"`
|
|
} `json:"choices"`
|
|
Usage *providerUsageEnvelope `json:"usage"`
|
|
}
|
|
if err := json.Unmarshal(a.pending, &resp); err == nil {
|
|
for _, choice := range resp.Choices {
|
|
a.consumeDelta(choice.Message)
|
|
}
|
|
a.recordUsage(resp.Usage)
|
|
}
|
|
}
|
|
return &providerAssembledObservation{
|
|
Content: a.content.String(),
|
|
Reasoning: a.reasoning.String(),
|
|
ToolCallNames: a.toolCallNames,
|
|
BodyBytes: a.bodyBytes,
|
|
}
|
|
}
|