Files
CLIProxyAPI/internal/runtime/executor/helps/response_model.go
Luis Pater f8467f07dc fix(executor): record authentic Devin and Gemini Interactions response models
- Devin: extract authentic upstream model name from parsed Usage.ModelName
  instead of synthesized interactions JSON or binary Connect frames. Keep
  response model empty when upstream does not report one.
- Gemini: support interaction.model and event_type terminal semantics
  in response model extractors for Gemini Interactions streaming.
- Add unit tests for Devin and Gemini Interactions response model observability.
2026-09-18 21:32:59 +08:00

426 lines
13 KiB
Go

package helps
import (
"strings"
"sync"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
)
const (
// maxResponseModelLength is a defensive bound on an upstream-controlled string
// reaching logs and usage records; known model ids stay under ~128 bytes.
maxResponseModelLength = 128
maxCodexResponseModelLength = maxResponseModelLength
// modelSubstitutionWarnWindow bounds how often one credential and model pair
// warns: on an affected credential every request is substituted.
modelSubstitutionWarnWindow = 10 * time.Minute
codexModelSubstitutionWarnWindow = modelSubstitutionWarnWindow
// modelSubstitutionWarnMaxEntries caps the throttle state, naturally bounded by
// credentials times models; memory safety wins over perfect throttling.
modelSubstitutionWarnMaxEntries = 1024
codexModelSubstitutionWarnMaxEntries = modelSubstitutionWarnMaxEntries
)
// extractResponseModelEvent returns the model an upstream reports serving, read
// from a raw JSON frame or an SSE line, and whether the event terminates the response.
func extractResponseModelEvent(payload []byte, provider string) (model string, terminal bool) {
data := jsonPayload(payload)
if len(data) == 0 {
return "", false
}
normProvider := strings.ToLower(strings.TrimSpace(provider))
switch normProvider {
case "codex":
return extractCodexResponseModelEvent(payload)
case "claude":
return extractClaudeResponseModelEvent(data)
case "gemini", "gemini-interactions", "vertex", "aistudio", "antigravity":
return extractGeminiResponseModelEvent(data)
default:
return extractGenericResponseModelEvent(data)
}
}
// extractClaudeResponseModelEvent extracts the response model from an Anthropic Claude response.
func extractClaudeResponseModelEvent(data []byte) (model string, terminal bool) {
eventType := gjson.GetBytes(data, "type").String()
switch eventType {
case "message_start":
m := gjson.GetBytes(data, "message.model")
if m.Type == gjson.String {
s := strings.TrimSpace(m.String())
if len(s) <= maxResponseModelLength {
return s, false
}
}
return "", false
case "message_stop":
return "", true
case "message":
m := gjson.GetBytes(data, "model")
if m.Type == gjson.String {
s := strings.TrimSpace(m.String())
if len(s) <= maxResponseModelLength {
return s, true
}
}
return "", true
default:
if !gjson.ValidBytes(data) {
return "", false
}
if m := gjson.GetBytes(data, "message.model"); m.Type == gjson.String {
s := strings.TrimSpace(m.String())
if len(s) <= maxResponseModelLength {
return s, false
}
} else if m := gjson.GetBytes(data, "model"); m.Type == gjson.String {
s := strings.TrimSpace(m.String())
if len(s) <= maxResponseModelLength {
return s, false
}
}
return "", false
}
}
// extractGeminiResponseModelEvent extracts the response model from a Google Gemini / Vertex / AIStudio response.
func extractGeminiResponseModelEvent(data []byte) (model string, terminal bool) {
if !gjson.ValidBytes(data) {
return "", false
}
m := gjson.GetBytes(data, "response.modelVersion")
if !m.Exists() || m.Type != gjson.String {
m = gjson.GetBytes(data, "modelVersion")
}
if !m.Exists() || m.Type != gjson.String {
m = gjson.GetBytes(data, "interaction.model")
}
if !m.Exists() || m.Type != gjson.String {
m = gjson.GetBytes(data, "model")
}
var served string
if m.Type == gjson.String {
served = strings.TrimSpace(m.String())
if len(served) > maxResponseModelLength {
served = ""
}
}
cand := gjson.GetBytes(data, "candidates.0.finishReason")
if !cand.Exists() {
cand = gjson.GetBytes(data, "response.candidates.0.finishReason")
}
terminal = cand.Exists() && cand.String() != ""
if !terminal {
eventType := gjson.GetBytes(data, "event_type").String()
if eventType == "" {
eventType = gjson.GetBytes(data, "type").String()
}
status := gjson.GetBytes(data, "interaction.status").String()
terminal = isInteractionsTerminal(eventType, status)
}
return served, terminal
}
// extractGenericResponseModelEvent extracts the response model from standard JSON / SSE responses.
func extractGenericResponseModelEvent(data []byte) (model string, terminal bool) {
if !gjson.ValidBytes(data) {
return "", false
}
if m := gjson.GetBytes(data, "response.model"); m.Type == gjson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
eventType := gjson.GetBytes(data, "type").String()
terminal := eventType == "response.completed" || eventType == "response.done" || eventType == "response.incomplete"
return served, terminal
}
}
if m := gjson.GetBytes(data, "interaction.model"); m.Type == gjson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
eventType := gjson.GetBytes(data, "event_type").String()
if eventType == "" {
eventType = gjson.GetBytes(data, "type").String()
}
status := gjson.GetBytes(data, "interaction.status").String()
terminal := isInteractionsTerminal(eventType, status)
return served, terminal
}
}
if m := gjson.GetBytes(data, "modelVersion"); m.Type == gjson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
cand := gjson.GetBytes(data, "candidates.0.finishReason")
return served, cand.Exists() && cand.String() != ""
}
}
if m := gjson.GetBytes(data, "response.modelVersion"); m.Type == gjson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
cand := gjson.GetBytes(data, "response.candidates.0.finishReason")
return served, cand.Exists() && cand.String() != ""
}
}
if m := gjson.GetBytes(data, "message.model"); m.Type == gjson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
return served, false
}
}
if m := gjson.GetBytes(data, "model"); m.Type == gjson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
objectType := gjson.GetBytes(data, "object").String()
finishReason := gjson.GetBytes(data, "choices.0.finish_reason").String()
status := gjson.GetBytes(data, "status").String()
terminal := objectType == "chat.completion" || finishReason != "" || status == "completed" || status == "incomplete"
return served, terminal
}
}
eventType := gjson.GetBytes(data, "event_type").String()
if eventType == "" {
eventType = gjson.GetBytes(data, "type").String()
}
status := gjson.GetBytes(data, "interaction.status").String()
if isInteractionsTerminal(eventType, status) || eventType == "message_stop" {
return "", true
}
return "", false
}
func isInteractionsTerminal(eventType, status string) bool {
switch eventType {
case "interaction.completed", "interaction.done", "interaction.failed", "interaction.cancelled":
return true
}
switch status {
case "completed", "incomplete", "cancelled", "failed":
return true
}
return false
}
// extractCodexResponseModelEvent returns the model a codex upstream reports serving, read
// from a raw JSON frame or an SSE line, and whether the event terminates the response.
func extractCodexResponseModelEvent(payload []byte) (model string, terminal bool) {
data := jsonPayload(payload)
if len(data) == 0 {
return "", false
}
// The event type is checked before the payload is validated, so the hot path
// (output deltas) stays a single cheap lookup.
carriesModel, terminal := codexResponseModelEventKind(gjson.GetBytes(data, "type").String())
if !carriesModel {
return "", false
}
if !gjson.ValidBytes(data) {
return "", false
}
// The value is upstream-controlled: reject non-string and oversized names
// rather than propagating them into logs and usage records.
modelResult := gjson.GetBytes(data, "response.model")
if modelResult.Type != gjson.String {
return "", terminal
}
model = strings.TrimSpace(modelResult.String())
if len(model) > maxCodexResponseModelLength {
return "", terminal
}
return model, terminal
}
// codexResponseModelEventKind reports whether a codex event embeds the authoritative
// response object, and whether that event terminates the response.
func codexResponseModelEventKind(eventType string) (carriesModel bool, terminal bool) {
switch strings.TrimSpace(eventType) {
case "response.created", "response.in_progress":
return true, false
case "response.completed", "response.incomplete", "response.done":
return true, true
default:
return false, false
}
}
// normalizeModelName lower-cases a model id and drops its thinking suffix,
// which never reaches the upstream request body.
func normalizeModelName(model string) string {
return strings.TrimSpace(thinking.ParseSuffix(strings.ToLower(strings.TrimSpace(model))).ModelName)
}
// normalizeCodexModelName lower-cases a model id and drops its thinking suffix,
// which never reaches the upstream request body.
func normalizeCodexModelName(model string) string {
return normalizeModelName(model)
}
func stripModelProviderPrefix(model string) string {
if idx := strings.LastIndex(model, "/"); idx >= 0 && idx < len(model)-1 {
return model[idx+1:]
}
return model
}
// IsCodexModelSubstituted reports whether the upstream served a model other than the
// requested one; a dated alias pins a snapshot of the same model and is accepted.
func IsCodexModelSubstituted(requested, served string) bool {
return IsModelSubstituted(requested, served)
}
// IsModelSubstituted reports whether the upstream served a model other than the
// requested one for any provider; dated aliases and snapshot pins are accepted.
func IsModelSubstituted(requested, served string) bool {
servedModel := normalizeModelName(served)
if servedModel == "" {
return false
}
requestedModel := normalizeModelName(requested)
if requestedModel == "" {
return false
}
if requestedModel == servedModel {
return false
}
if isDatedModelAlias(requestedModel, servedModel) || isDatedModelAlias(servedModel, requestedModel) {
return false
}
cleanReq := stripModelProviderPrefix(requestedModel)
cleanSrv := stripModelProviderPrefix(servedModel)
if cleanReq == cleanSrv {
return false
}
if isDatedModelAlias(cleanReq, cleanSrv) || isDatedModelAlias(cleanSrv, cleanReq) {
return false
}
reqNoLatest := strings.TrimSuffix(cleanReq, "-latest")
srvNoLatest := strings.TrimSuffix(cleanSrv, "-latest")
if reqNoLatest == srvNoLatest {
return false
}
if isDatedModelAlias(reqNoLatest, srvNoLatest) || isDatedModelAlias(srvNoLatest, reqNoLatest) {
return false
}
return true
}
func isDatedModelAlias(base, dated string) bool {
prefix := base + "-"
if !strings.HasPrefix(dated, prefix) {
return false
}
suffix := dated[len(prefix):]
return isModelDateSuffix(suffix) || isModelNumericVersionSuffix(suffix)
}
// isCodexDatedModelAlias reports whether dated is base plus a release date suffix,
// which upstreams use to pin the exact snapshot of the same model.
func isCodexDatedModelAlias(base, dated string) bool {
return isDatedModelAlias(base, dated)
}
func isModelDateSuffix(suffix string) bool {
switch len(suffix) {
case len("YYYY-MM-DD"):
if suffix[4] != '-' || suffix[7] != '-' {
return false
}
return isModelDigits(suffix[:4]) && isModelDigits(suffix[5:7]) && isModelDigits(suffix[8:])
case len("YYYYMMDD"):
return isModelDigits(suffix)
default:
return false
}
}
// isCodexModelDateSuffix reports whether suffix is a YYYY-MM-DD or YYYYMMDD date.
func isCodexModelDateSuffix(suffix string) bool {
return isModelDateSuffix(suffix)
}
func isModelNumericVersionSuffix(suffix string) bool {
if len(suffix) == 3 && isModelDigits(suffix) {
return true
}
return false
}
func isModelDigits(value string) bool {
if value == "" {
return false
}
for i := 0; i < len(value); i++ {
if value[i] < '0' || value[i] > '9' {
return false
}
}
return true
}
// isCodexModelDigits reports whether value is a non-empty run of ASCII digits.
func isCodexModelDigits(value string) bool {
return isModelDigits(value)
}
type codexModelSubstitutionKey struct {
provider string
authID string
requested string
served string
}
// codexModelSubstitutionThrottle records the last warning per key; nowFunc is
// injectable so tests can advance the window without sleeping.
type codexModelSubstitutionThrottle struct {
mu sync.Mutex
nowFunc func() time.Time
lastWarn map[codexModelSubstitutionKey]time.Time
}
func newCodexModelSubstitutionThrottle(nowFunc func() time.Time) *codexModelSubstitutionThrottle {
if nowFunc == nil {
nowFunc = time.Now
}
return &codexModelSubstitutionThrottle{
nowFunc: nowFunc,
lastWarn: make(map[codexModelSubstitutionKey]time.Time),
}
}
// allow reports whether the key may emit a warning now and records the decision.
// Suppressed repeats stay silent instead of moving to a lower level.
func (t *codexModelSubstitutionThrottle) allow(key codexModelSubstitutionKey) bool {
if t == nil {
return true
}
t.mu.Lock()
defer t.mu.Unlock()
now := t.nowFunc()
if last, ok := t.lastWarn[key]; ok && now.Sub(last) < codexModelSubstitutionWarnWindow {
return false
}
if len(t.lastWarn) >= codexModelSubstitutionWarnMaxEntries {
for storedKey, storedAt := range t.lastWarn {
if now.Sub(storedAt) >= codexModelSubstitutionWarnWindow {
delete(t.lastWarn, storedKey)
}
}
if len(t.lastWarn) >= codexModelSubstitutionWarnMaxEntries {
clear(t.lastWarn)
}
}
t.lastWarn[key] = now
return true
}
// codexModelSubstitutionWarns throttles substitution warnings process-wide.
var codexModelSubstitutionWarns = newCodexModelSubstitutionThrottle(time.Now)