mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
回答「opencodego 的用量与费用透传呢」时逐字段核对上游产出,发现 usage 漏了
一项、费用整项丢失。
## 上游实际发什么(实测 opencode.ai/zen/go/v1)
{
"choices": [...],
"usage": { "prompt_tokens": 37, "completion_tokens": 40, "total_tokens": 77,
"prompt_cache_hit_tokens": 0, "prompt_cache_miss_tokens": 37,
"prompt_tokens_details": {"cached_tokens": 0},
"completion_tokens_details": {"reasoning_tokens": 40} },
"cost": "0"
}
cost 在**顶层**且是**字符串**。流式时还会单独发一帧:
{"choices":[],"cost":"0"}
## 此前丢了两样
1. completion_tokens_details.reasoning_tokens —— 输出里有多少是思考 token。
没有它,客户端无法判断 completion_tokens 里多少是可见回答、多少是思考,
而两者都按输出计费。
2. cost —— 唯一的费用信号,网关整个丢弃。Go 订阅是包月制恒为 "0",
但 Zen 按量付费模型(以及未来的其它源)有信息量。
顺带修掉一处流式/非流式不一致:命中缓存时上游同时给
prompt_tokens_details.cached_tokens 和独立的 hit/miss,流式路径写成了 elseif,
只留 details,与非流式产出不同(只认独立字段的老客户端会看不到缓存)。
## 实现
- types.TokenUsage += CompletionTokensDetails;UnifiedResponse / UnifiedChunk += Cost
- opencodego/opencodezen 适配器映射两个字段;空 choices 帧改成 usage 与 cost
都可带(早退只带 usage 会把同帧的 cost 丢干净 —— 新测试先抓到的就是这个)
- Gateway ChatCompletion / ChatChunk += cost,随终帧发(对齐上游的
{"choices":[],"cost":"0"} 形态)
- Go 兜底 standardSSEChunk 同步支持(openai 系适配器不再漏 reasoning_tokens;
纯 cost 帧不再被整体丢弃),新增 rawCostString 兼容字符串/数字两种形态
费用只做**搬运**:不解析、不换算、不汇总 —— 它是上游事实,且只有部分上游提供。
## 验证
经网关实测 gozen:deepseek-v4.1-flash,流式与非流式产出逐字段一致:
prompt_tokens_details.cached_tokens=6784
prompt_cache_hit_tokens=6784 / miss=148
completion_tokens_details.reasoning_tokens=16
cost="0"
测试:TestOpenCodeCostAndReasoningPassthrough(含「无数据不得凭空造字段」反例)、
TestOpenCodeStreamCacheFieldsMatchNonStream、TestTokenUsageMarshalsCompletionTokensDetails。
266 lines
11 KiB
Go
266 lines
11 KiB
Go
// Package types defines the unified (OpenAI-compatible) wire format that the
|
|
// gateway exposes to its clients, plus the unified internal representation.
|
|
package types
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ErrBusy is the soft "source at capacity" sentinel shared by the provider
|
|
// layer (returns it) and the scheduler layer (reacts to it): busy is not a
|
|
// failure, so no cooldown/preference penalty is recorded, and gateways map
|
|
// it to HTTP 429. Defined here so the scheduler does not depend on the
|
|
// provider package (which pulls in the Lua runtime).
|
|
var ErrBusy = errors.New("provider busy")
|
|
|
|
// ---- OpenAI wire request (gateway input) ----
|
|
|
|
type ChatRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []ChatMessage `json:"messages"`
|
|
Temperature *float64 `json:"temperature,omitempty"`
|
|
MaxTokens int `json:"max_tokens,omitempty"`
|
|
Stream bool `json:"stream,omitempty"`
|
|
Tools []interface{} `json:"tools,omitempty"`
|
|
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
|
DisableThinking bool `json:"disable_thinking"`
|
|
ExtraBody map[string]interface{} `json:"-"`
|
|
|
|
// ClientSession 是客户端自带的会话标识(请求头 x-session-affinity 等)。
|
|
// 不参与序列化:它只用于让适配器生成稳定的上游会话,不应泄漏给上游 body。
|
|
ClientSession string `json:"-"`
|
|
}
|
|
|
|
func (r *ChatRequest) MarshalJSON() ([]byte, error) {
|
|
type Alias ChatRequest
|
|
data, err := json.Marshal((*Alias)(r))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(r.ExtraBody) == 0 {
|
|
return data, nil
|
|
}
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
for k, v := range r.ExtraBody {
|
|
raw[k] = v
|
|
}
|
|
return json.Marshal(raw)
|
|
}
|
|
|
|
// ChatMessage supports both plain string content and multimodal arrays
|
|
// (RawMessage preserves whatever the client sent for the adapter to process).
|
|
type ChatMessage struct {
|
|
Role string `json:"role"`
|
|
Content json.RawMessage `json:"content,omitempty"`
|
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
|
}
|
|
|
|
func StringContent(s string) json.RawMessage { b, _ := json.Marshal(s); return b }
|
|
|
|
// isEmptyJSONArray reports whether raw is the literal empty JSON array [].
|
|
func isEmptyJSONArray(raw json.RawMessage) bool {
|
|
t := bytes.TrimSpace(raw)
|
|
return len(t) == 2 && t[0] == '[' && t[1] == ']'
|
|
}
|
|
|
|
// UnmarshalJSON decodes a chat message, normalising an empty-array content
|
|
// ("content":[]) to an empty string and dropping an empty tool_calls array.
|
|
//
|
|
// Why this exists: Lua adapters json.decode the request and re-encode it, and
|
|
// an empty Lua table is indistinguishable from an empty JSON array — the
|
|
// encoder emits {} for both. Agent clients serialise an assistant turn that
|
|
// carries tool_calls and no text as content:[], so every pass-through adapter
|
|
// turned it into content:{} — a shape that is not valid OpenAI (content is
|
|
// string | array of parts | null) and that real upstreams reject with
|
|
// "400 invalid arguments". Normalising at the decode boundary fixes every
|
|
// adapter at once, including ones added later.
|
|
func (m *ChatMessage) UnmarshalJSON(b []byte) error {
|
|
type alias ChatMessage
|
|
var a alias
|
|
if err := json.Unmarshal(b, &a); err != nil {
|
|
return err
|
|
}
|
|
*m = ChatMessage(a)
|
|
if isEmptyJSONArray(m.Content) {
|
|
m.Content = StringContent("")
|
|
}
|
|
if isEmptyJSONArray(m.ToolCalls) {
|
|
m.ToolCalls = nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type ToolCall struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Arguments map[string]interface{} `json:"arguments"`
|
|
}
|
|
|
|
// ---- Unified internal representation (what adapters produce) ----
|
|
|
|
type UnifiedResponse struct {
|
|
Model string `json:"model,omitempty"`
|
|
Content string `json:"content"`
|
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
|
FinishReason string `json:"finish_reason,omitempty"`
|
|
TokenUsage TokenUsage `json:"token_usage"`
|
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
|
// ImageData used by image-generation adapters.
|
|
ImageData []ImageData `json:"image_data,omitempty"`
|
|
// Cost is the upstream-reported charge for this request, passed through
|
|
// verbatim (OpenCode Zen/Go put a decimal **string** at the response top
|
|
// level). Deliberately not parsed or summed by the gateway: it is an
|
|
// upstream fact, and only some upstreams report it at all.
|
|
Cost string `json:"cost,omitempty"`
|
|
}
|
|
|
|
type TokenUsage struct {
|
|
Prompt int `json:"prompt"`
|
|
Completion int `json:"completion"`
|
|
Total int `json:"total"`
|
|
// PromptTokensDetails mirrors the OpenAI v2 usage.prompt_tokens_details
|
|
// object so cache-hit counts reported by OpenAI-compatible upstreams
|
|
// (and by adapters that normalize their own cache fields into it) pass
|
|
// through to clients that read it — dsh reads cached_tokens from here.
|
|
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
|
// PromptCacheHit / PromptCacheMiss carry the DeepSeek-legacy standalone
|
|
// fields; dsh falls back to prompt_cache_hit_tokens when
|
|
// prompt_tokens_details.cached_tokens is absent.
|
|
PromptCacheHit int `json:"prompt_cache_hit_tokens,omitempty"`
|
|
PromptCacheMiss int `json:"prompt_cache_miss_tokens,omitempty"`
|
|
// CompletionTokensDetails mirrors the OpenAI v2 usage.completion_tokens_details
|
|
// object. OpenCode Zen/Go report how much of the completion was thinking
|
|
// tokens here; without it clients cannot tell how much of the billed output
|
|
// was reasoning rather than visible answer.
|
|
CompletionTokensDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"`
|
|
}
|
|
|
|
// CompletionTokensDetails is the OpenAI v2 completion_tokens_details object.
|
|
type CompletionTokensDetails struct {
|
|
ReasoningTokens int `json:"reasoning_tokens"`
|
|
}
|
|
|
|
// PromptTokensDetails is the OpenAI v2 prompt_tokens_details object. Only
|
|
// CachedTokens is emitted (omitempty drops the whole object when zero).
|
|
type PromptTokensDetails struct {
|
|
// CachedTokens is always emitted (even 0) so clients can distinguish
|
|
// "upstream reports cache, this request missed" from "no cache data".
|
|
CachedTokens int `json:"cached_tokens"`
|
|
}
|
|
|
|
// MarshalJSON emits both the legacy short keys (prompt/completion/total, used
|
|
// by the internal unified representation and older clients) and the OpenAI
|
|
// standard keys (prompt_tokens/completion_tokens/total_tokens). Standard
|
|
// clients such as DSH and DevEco Code read the *_tokens fields.
|
|
func (t TokenUsage) MarshalJSON() ([]byte, error) {
|
|
// Auto-generate prompt_tokens_details from legacy DeepSeek fields when
|
|
// the upstream adapter only set the standalone hit count (openai.lua
|
|
// does this in Lua, but other adapters or the standardSSEChunk fallback
|
|
// may not). dsh reads prompt_tokens_details.cached_tokens first.
|
|
pdetails := t.PromptTokensDetails
|
|
if pdetails == nil && t.PromptCacheHit > 0 {
|
|
pdetails = &PromptTokensDetails{CachedTokens: t.PromptCacheHit}
|
|
}
|
|
return json.Marshal(struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
TotalTokens int `json:"total_tokens"`
|
|
Prompt int `json:"prompt"`
|
|
Completion int `json:"completion"`
|
|
Total int `json:"total"`
|
|
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
|
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
|
|
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens,omitempty"`
|
|
CompletionDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"`
|
|
}{
|
|
PromptTokens: t.Prompt,
|
|
CompletionTokens: t.Completion,
|
|
TotalTokens: t.Total,
|
|
Prompt: t.Prompt,
|
|
Completion: t.Completion,
|
|
Total: t.Total,
|
|
PromptTokensDetails: pdetails,
|
|
PromptCacheHitTokens: t.PromptCacheHit,
|
|
PromptCacheMissTokens: t.PromptCacheMiss,
|
|
CompletionDetails: t.CompletionTokensDetails,
|
|
})
|
|
}
|
|
|
|
type ImageData struct {
|
|
B64JSON string `json:"b64_json,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
Revised string `json:"revised_prompt,omitempty"`
|
|
}
|
|
|
|
// ---- Image generation (OpenAI /v1/images/generations wire) ----
|
|
|
|
type ImageGenRequest struct {
|
|
Model string `json:"model"`
|
|
Prompt string `json:"prompt"`
|
|
N int `json:"n,omitempty"`
|
|
Size string `json:"size,omitempty"`
|
|
ResponseFormat string `json:"response_format,omitempty"`
|
|
}
|
|
|
|
type ImageGenResponse struct {
|
|
Created int64 `json:"created"`
|
|
Data []ImageData `json:"data"`
|
|
}
|
|
|
|
// ---- Unified streaming chunk produced by adapters ----
|
|
|
|
// UnifiedChunk is one streamed delta. ToolCalls carries the raw upstream
|
|
// streaming tool_calls array (incremental fragments with an index field), which
|
|
// OpenAI-compatible clients accumulate themselves.
|
|
type UnifiedChunk struct {
|
|
Content string `json:"content"`
|
|
Done bool `json:"done"`
|
|
// FinishReason carries the upstream finish/stop reason ("tool_calls",
|
|
// "length", ...) when the adapter provides it; the gateway emits it on
|
|
// the terminating chunk instead of the default "stop".
|
|
FinishReason string `json:"finish_reason,omitempty"`
|
|
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
|
// Usage carries the upstream token usage when the stream chunk provides
|
|
// it (OpenAI-style streams attach usage to some chunks; the final one
|
|
// often has empty choices). Gateway uses it to emit exact usage in the
|
|
// final stream chunk instead of estimates.
|
|
Usage *TokenUsage `json:"usage,omitempty"`
|
|
// Cost is upstream-reported charge for this request (decimal string),
|
|
// passed through verbatim; OpenCode sends it on its own stream chunk.
|
|
Cost string `json:"cost,omitempty"`
|
|
}
|
|
|
|
// Meta passed to Lua build_headers hook
|
|
type BuildMeta struct {
|
|
URL string `json:"url"`
|
|
Method string `json:"method"`
|
|
Body string `json:"body"`
|
|
APIKey string `json:"api_key"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
Source map[string]interface{} `json:"source"`
|
|
}
|
|
|
|
func Now() int64 { return time.Now().Unix() }
|
|
|
|
// OneLine flattens an error string to a single line capped at n chars.
|
|
// Shared by the provider layer (upstream error reasons) and the gateway
|
|
// layer (client-facing AUTO-chain summaries).
|
|
func OneLine(s string, n int) string {
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if len(s) > n {
|
|
s = s[:n] + "..."
|
|
}
|
|
return s
|
|
}
|