近 24 小时请求量
@@ -1196,7 +1217,8 @@ function loadStats() {
document.getElementById('statTotal').textContent = fmt(d.total);
document.getElementById('statReqSub').textContent = '成功率 ' + pct(d.success_rate);
document.getElementById('statTokens').textContent = fmt(tokens);
- document.getElementById('statCredits').textContent = 'Credits 消耗 ' + fmtCredits(d.credits);
+ renderCredits(d);
+ renderQuotaDetails(d);
document.getElementById('statPrompt').textContent = fmt(d.prompt_tokens);
document.getElementById('statCached').textContent = '缓存命中 ' + fmt(d.cached_tokens);
document.getElementById('statCompletion').textContent = fmt(d.completion_tokens);
@@ -1207,6 +1229,141 @@ function loadStats() {
}).catch(function(e) { showToast(e.message, 'error'); });
}
+/*
+ * Prefer the authoritative cycle allowance used by Qoder's official /usage
+ * view. Local cycle_credits remains a fallback for temporary OpenAPI outages;
+ * it only covers traffic observed by this bridge and is therefore never mixed
+ * with the account-wide official value.
+ */
+function renderCredits(d) {
+ var el = document.getElementById('statCredits');
+ var lifetime = Number(d.credits || 0);
+ var cycle = Number(d.cycle_credits || 0);
+ var resetMs = Number(d.next_reset_ms || 0);
+ var acct = d.account || {};
+ var quota = acct.user_quota || null;
+ var title = ['本服务累计消耗 ' + fmtCredits(lifetime)];
+
+ el.textContent = '';
+ if (quota) {
+ el.appendChild(document.createTextNode(
+ '套餐 ' + fmtCredits(Number(quota.used || 0)) + ' / ' + fmtCredits(Number(quota.total || 0)) +
+ ' · 剩 ' + fmtCredits(Number(quota.remaining || 0))
+ ));
+ title.push('套餐额度:已用 ' + fmtCredits(Number(quota.used || 0)) +
+ ' / ' + fmtCredits(Number(quota.total || 0)) +
+ ',剩余 ' + fmtCredits(Number(quota.remaining || 0)));
+ if (acct.add_on_quota) {
+ title.push('加购额度:已用 ' + fmtCredits(Number(acct.add_on_quota.used || 0)) +
+ ' / ' + fmtCredits(Number(acct.add_on_quota.total || 0)) +
+ ',剩余 ' + fmtCredits(Number(acct.add_on_quota.remaining || 0)));
+ }
+ if (acct.org_resource_package) {
+ var org = acct.org_resource_package;
+ title.push('组织资源包:已用 ' + fmtCredits(Number(org.used || 0)) +
+ ' / ' + fmtCredits(Number(org.cap || 0)) +
+ ',剩余 ' + fmtCredits(Number(org.remaining || 0)) +
+ (org.available ? '(可用)' : '(不可用)'));
+ }
+ } else if (resetMs > 0) {
+ el.appendChild(document.createTextNode('本服务周期内 ' + fmtCredits(cycle)));
+ title.push('官方额度暂不可用;当前数字仅统计本服务观察到的请求');
+ } else {
+ el.appendChild(document.createTextNode('Credits 消耗 ' + fmtCredits(lifetime)));
+ }
+
+ if (resetMs > 0) {
+ var left = Math.ceil((resetMs - Date.now()) / 86400000);
+ el.appendChild(document.createTextNode(' · ' + fmtMonthDay(resetMs) + ' 重置'));
+ if (left > 0) {
+ el.appendChild(document.createTextNode(' · 剩 ' + left + ' 天'));
+ }
+ }
+ el.title = title.join('\n');
+
+ if (acct.is_quota_exceeded) {
+ el.appendChild(makeBadge('额度已超', 'bad'));
+ } else if (acct.tag) {
+ el.appendChild(makeBadge(acct.tag, 'neutral'));
+ }
+}
+
+/*
+ * Render every allowance pool returned by Qoder as visible content. The
+ * compact stat line intentionally remains a plan summary; this table prevents
+ * add-on and organization packages from being hidden behind a hover tooltip.
+ */
+function renderQuotaDetails(d) {
+ var card = document.getElementById('quotaCard');
+ var table = document.getElementById('quotaTable');
+ var summary = document.getElementById('quotaSummary');
+ var acct = d.account || {};
+ var pools = [];
+
+ if (acct.user_quota) {
+ pools.push({ name: '套餐额度', quota: acct.user_quota, totalKey: 'total', available: true });
+ }
+ if (acct.add_on_quota) {
+ pools.push({ name: '加购额度', quota: acct.add_on_quota, totalKey: 'total', available: true });
+ }
+ if (acct.org_resource_package) {
+ pools.push({
+ name: '组织资源包',
+ quota: acct.org_resource_package,
+ totalKey: 'cap',
+ available: Boolean(acct.org_resource_package.available)
+ });
+ }
+
+ if (pools.length === 0) {
+ card.classList.add('hidden');
+ table.textContent = '';
+ summary.textContent = '';
+ return;
+ }
+
+ card.classList.remove('hidden');
+ table.textContent = '';
+ var totalRemaining = 0;
+ pools.forEach(function(pool) {
+ var used = Number(pool.quota.used || 0);
+ var total = Number(pool.quota[pool.totalKey] || 0);
+ var remaining = Number(pool.quota.remaining || 0);
+ var ratio = total > 0 ? Math.min(Math.max(used / total, 0), 1) : 0;
+ var statusText = pool.available ? (remaining > 0 ? '可用' : '已用尽') : '不可用';
+ var statusKind = pool.available && remaining > 0 ? 'ok' : (pool.available ? 'bad' : 'neutral');
+ totalRemaining += remaining;
+
+ var tr = document.createElement('tr');
+ tr.innerHTML =
+ '
' + pool.name + ' | ' +
+ '
' + fmtCredits(used) + ' / ' + (total >= 0 ? fmtCredits(total) : '—') + ' | ' +
+ '
' + fmtCredits(remaining) + ' | ' +
+ '
' +
+ ' ' + Math.round(ratio * 100) + '% | ' +
+ '
' + statusText + ' | ';
+ table.appendChild(tr);
+ });
+
+ var resetMs = Number(d.next_reset_ms || 0);
+ summary.textContent = '合计剩余 ' + fmtCredits(totalRemaining) +
+ (resetMs > 0 ? ' · ' + fmtMonthDay(resetMs) + ' 重置' : '');
+}
+
+function makeBadge(text, kind) {
+ var b = document.createElement('span');
+ b.className = 'badge ' + kind;
+ b.textContent = text;
+ return b;
+}
+
+function fmtMonthDay(ms) {
+ var t = new Date(ms);
+ var m = t.getMonth() + 1;
+ var day = t.getDate();
+ return (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day);
+}
+
function fmtCredits(v) {
var n = Number(v || 0);
if (n === 0) return '0';
diff --git a/auth/auth.go b/auth/auth.go
index da59686..4b04b15 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -7,7 +7,7 @@ import (
"context"
"crypto/aes"
"crypto/cipher"
- "crypto/md5"
+ cosySignatureHash "crypto/md5"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
@@ -17,6 +17,7 @@ import (
"errors"
"fmt"
"io"
+ "log"
"net/http"
"net/url"
"os"
@@ -32,15 +33,17 @@ import (
// --- Region Configuration ---
type RegionConfig struct {
- Name string
- AuthBase string
- ChatBase string
+ Name string
+ AuthBase string
+ ChatBase string
+ OpenAPIBase string
}
var CN = &RegionConfig{
- Name: "cn",
- AuthBase: "https://gateway.qoder.com.cn",
- ChatBase: "https://gateway.qoder.com.cn",
+ Name: "cn",
+ AuthBase: "https://gateway.qoder.com.cn",
+ ChatBase: "https://gateway.qoder.com.cn",
+ OpenAPIBase: "https://openapi.qoder.com.cn",
}
func Resolve(pat string) (string, *RegionConfig) {
@@ -59,6 +62,10 @@ func ModelListURL(region *RegionConfig) string {
return region.ChatBase + "/algo/api/v2/model/list?Encode=1"
}
+func OpenAPIURL(region *RegionConfig, path string) string {
+ return region.OpenAPIBase + path
+}
+
func FetchModelCatalog(ctx context.Context, sess *SessionContext, region *RegionConfig) (map[string]interface{}, error) {
return CallGet(ctx, sess, ModelListURL(region))
}
@@ -148,7 +155,9 @@ func CurrentDate() string {
func Sign(date string) string {
s := appCode + "&" + getSecret() + "&" + date
- h := md5.Sum([]byte(s))
+ // nosemgrep: go.lang.security.audit.crypto.use_of_weak_crypto.use-of-md5 -- Qoder COSY protocol mandates MD5 for this wire signature.
+ // #nosec G401 -- Legacy Qoder COSY wire signature; changing MD5 breaks authentication.
+ h := cosySignatureHash.Sum([]byte(s))
return fmt.Sprintf("%x", h)
}
@@ -219,7 +228,9 @@ func aesEncrypt(plain, key []byte) ([]byte, error) {
}
func md5Hex(s string) string {
- h := md5.Sum([]byte(s))
+ // nosemgrep: go.lang.security.audit.crypto.use_of_weak_crypto.use-of-md5 -- Qoder COSY protocol mandates MD5 for this wire signature.
+ // #nosec G401 -- Legacy Qoder COSY wire signature; changing MD5 breaks authentication.
+ h := cosySignatureHash.Sum([]byte(s))
return fmt.Sprintf("%x", h)
}
@@ -546,6 +557,190 @@ func RefreshJobToken(ctx context.Context, personalToken, refreshToken, securityO
return requestJobToken(ctx, personalToken, refreshToken, securityOauthToken, true, machineID, machineToken, machineType, region)
}
+// --- Account status (subscription tier + billing cycle) ---
+
+// userStatusInnerStruct is the /user/status payload. Field order MUST match
+// the official client's dict insertion order for signature compatibility.
+// Only userId is populated; the token fields are sent empty because this call
+// is authenticated by the machine-identity signature, not by the session.
+type userStatusInnerStruct struct {
+ UserID string `json:"userId"`
+ PersonalToken string `json:"personalToken"`
+ SecurityOauthToken string `json:"securityOauthToken"`
+ RefreshToken string `json:"refreshToken"`
+ NeedRefresh bool `json:"needRefresh"`
+ AuthInfo json.RawMessage `json:"authInfo"`
+}
+
+// AccountStatus combines identity metadata from the gateway status endpoint
+// with the authoritative allowance returned by Qoder OpenAPI. NextResetAtMs is
+// the moment the current subscription allowance expires and refreshes.
+type AccountStatus struct {
+ // UserType is the account's real tier as reported by the gateway
+ // ("teams", "personal_standard", ...). The jobToken response does NOT
+ // carry this field, so callers must not infer it from there.
+ UserType string
+ // Plan is the raw plan identifier ("PLAN_TIER_TEAM", ...).
+ Plan string
+ // UserTag is the human-facing plan label ("Teams", ...).
+ UserTag string
+ // OrgName is the organization the account belongs to ("" for personal).
+ OrgName string
+ // NextResetAtMs is the subscription refresh instant in epoch millis.
+ // 0 means the gateway did not report one.
+ NextResetAtMs int64
+ // IsQuotaExceeded reports whether the account is currently out of
+ // allowance. Once quota usage has been fetched, this is the OpenAPI
+ // verdict used by the official client rather than the reduced gateway
+ // status field.
+ IsQuotaExceeded bool
+ TotalUsagePercentage float64
+ UserQuota *Quota
+ AddOnQuota *Quota
+ OrgResourcePackage *OrgResourcePackage
+}
+
+// Quota is an authoritative credit allowance returned by Qoder OpenAPI.
+// Percentage is a ratio in [0,1], matching the official client payload.
+type Quota struct {
+ Total float64 `json:"total"`
+ Used float64 `json:"used"`
+ Remaining float64 `json:"remaining"`
+ Percentage float64 `json:"percentage"`
+ Unit string `json:"unit"`
+ DetailURL string `json:"detailUrl,omitempty"`
+}
+
+// OrgResourcePackage is the shared organization credit pool. Available is
+// authoritative: a positive cap does not necessarily mean the current member
+// may consume it.
+type OrgResourcePackage struct {
+ Used float64 `json:"used"`
+ Cap float64 `json:"cap"`
+ Remaining float64 `json:"remaining"`
+ Percentage float64 `json:"percentage"`
+ Available bool `json:"available"`
+ Unit string `json:"unit"`
+}
+
+// QuotaUsage is the full cycle-scoped allowance returned by the same OpenAPI
+// endpoint used by the official client's /usage view.
+type QuotaUsage struct {
+ UserID string `json:"userId"`
+ UserType string `json:"userType"`
+ UsageType string `json:"usageType"`
+ TotalUsagePercentage float64 `json:"totalUsagePercentage"`
+ IsQuotaExceeded bool `json:"isQuotaExceeded"`
+ ExpiresAtMs int64 `json:"expiresAt"`
+ UpgradeURL string `json:"upgradeUrl"`
+ UserQuota *Quota `json:"userQuota"`
+ AddOnQuota *Quota `json:"addOnQuota,omitempty"`
+ OrgResourcePackage *OrgResourcePackage `json:"orgResourcePackage,omitempty"`
+ IsPlanQuotaProrated bool `json:"isPlanQuotaProrated"`
+}
+
+// FetchUserStatus queries the account's subscription state.
+//
+// The endpoint is authenticated by the machine-identity signature only (no
+// bearer session), so it is safe to call right after a jobToken exchange and
+// before any session is constructed.
+func FetchUserStatus(ctx context.Context, userID, machineID, machineToken, machineType string, region *RegionConfig) (map[string]interface{}, error) {
+ if region == nil {
+ region = CN
+ }
+ urlStr := AuthURL(region, "/algo/api/v3/user/status?Encode=1")
+ inner := userStatusInnerStruct{
+ UserID: userID,
+ NeedRefresh: false,
+ AuthInfo: emptyJSON,
+ }
+ innerJSON, err := marshalNoEscape(inner)
+ if err != nil {
+ return nil, err
+ }
+ outer := jobTokenOuterStruct{
+ Payload: string(innerJSON),
+ EncodeVersion: "1",
+ }
+ return postEncoded(ctx, urlStr, outer, machineID, machineToken, machineType)
+}
+
+// FetchQuotaUsage retrieves the authoritative current-cycle allowance used by
+// the official /usage view. The bearer is the securityOauthToken returned by
+// the existing jobToken exchange; no additional token rotation is required.
+func FetchQuotaUsage(ctx context.Context, bearer string, region *RegionConfig) (*QuotaUsage, error) {
+ if bearer == "" {
+ return nil, fmt.Errorf("quota usage requires a security OAuth token")
+ }
+ if region == nil {
+ region = CN
+ }
+ urlStr := OpenAPIURL(region, "/api/v2/quota/usage")
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Authorization", "Bearer "+bearer)
+
+ started := time.Now()
+ log.Printf("[auth] OpenAPI request: method=GET url=%s headers={Accept: application/json, Authorization: Bearer [REDACTED]} body=
", urlStr)
+ resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
+ if err != nil {
+ log.Printf("[auth] OpenAPI request failed: method=GET url=%s duration=%s error=%v", urlStr, time.Since(started), err)
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, err
+ }
+ log.Printf("[auth] OpenAPI response: method=GET url=%s status=%d duration=%s headers=%v body=%s",
+ urlStr, resp.StatusCode, time.Since(started), resp.Header, body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("quota usage returned HTTP %d: %s", resp.StatusCode, body)
+ }
+ var usage QuotaUsage
+ if err := json.Unmarshal(body, &usage); err != nil {
+ return nil, fmt.Errorf("decode quota usage: %w", err)
+ }
+ if usage.UserID == "" || usage.UserQuota == nil {
+ return nil, fmt.Errorf("quota usage response shape unexpected")
+ }
+ return &usage, nil
+}
+
+// ParseAccountStatus converts a raw /user/status response into AccountStatus.
+// It returns false when the response carries no recognizable account fields,
+// letting callers keep any previously cached state instead of zeroing it out.
+func ParseAccountStatus(raw map[string]interface{}) (AccountStatus, bool) {
+ if raw == nil {
+ return AccountStatus{}, false
+ }
+ userType, _ := raw["userType"].(string)
+ plan, _ := raw["plan"].(string)
+ if userType == "" && plan == "" && raw["nextResetAt"] == nil {
+ return AccountStatus{}, false
+ }
+ st := AccountStatus{
+ UserType: userType,
+ Plan: plan,
+ }
+ st.UserTag, _ = raw["userTag"].(string)
+ st.OrgName, _ = raw["orgName"].(string)
+ st.NextResetAtMs = toInt64Ms(raw["nextResetAt"])
+ st.IsQuotaExceeded, _ = raw["isQuotaExceeded"].(bool)
+ return st, true
+}
+
+// toInt64Ms coerces an epoch-millis field arriving as a JSON number.
+func toInt64Ms(v interface{}) int64 {
+ if f, ok := v.(float64); ok {
+ return int64(f)
+ }
+ return 0
+}
+
// drainBody reads and discards the remaining response body (up to a cap) so
// the underlying TCP connection can be returned to the pool for reuse.
// Must be called before resp.Body.Close() on error paths where the body was
diff --git a/auth/auth_test.go b/auth/auth_test.go
index 238c1cc..65c76e1 100644
--- a/auth/auth_test.go
+++ b/auth/auth_test.go
@@ -349,3 +349,37 @@ func TestDetectInStreamBusyIgnoresPlainDeltas(t *testing.T) {
t.Error("plain content delta must not be classified as busy")
}
}
+
+func TestFetchQuotaUsageUsesSecurityOauthBearer(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v2/quota/usage" {
+ t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer sot-test" {
+ t.Errorf("Authorization = %q, want security OAuth bearer", got)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"userId":"u1","userType":"teams","totalUsagePercentage":0.98,"isQuotaExceeded":false,"expiresAt":1790265600000,"userQuota":{"total":3000,"used":2939,"remaining":61,"percentage":0.98,"unit":"credits"},"orgResourcePackage":{"used":0,"cap":4000,"remaining":0,"percentage":0,"available":false,"unit":"credits"}}`)
+ }))
+ defer srv.Close()
+
+ usage, err := FetchQuotaUsage(context.Background(), "sot-test", &RegionConfig{OpenAPIBase: srv.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if usage.UserQuota == nil || usage.UserQuota.Used != 2939 || usage.UserQuota.Remaining != 61 {
+ t.Errorf("unexpected user quota: %+v", usage.UserQuota)
+ }
+ if usage.OrgResourcePackage == nil || usage.OrgResourcePackage.Cap != 4000 || usage.OrgResourcePackage.Available {
+ t.Errorf("unexpected organization package: %+v", usage.OrgResourcePackage)
+ }
+ if usage.ExpiresAtMs != 1790265600000 || usage.TotalUsagePercentage != 0.98 {
+ t.Errorf("unexpected cycle metadata: %+v", usage)
+ }
+}
+
+func TestFetchQuotaUsageRequiresBearer(t *testing.T) {
+ if _, err := FetchQuotaUsage(context.Background(), "", CN); err == nil {
+ t.Fatal("expected an error for an empty security OAuth token")
+ }
+}
diff --git a/bridge/bridge.go b/bridge/bridge.go
index 41b942f..cb9c5bd 100644
--- a/bridge/bridge.go
+++ b/bridge/bridge.go
@@ -29,6 +29,11 @@ import (
var (
refreshMarginMs = int64(2 * 3600 * 1000) // 2 hours
catalogTTL = float64(600) // 10 minutes
+ // accountTTL bounds how long a cached /user/status result is trusted.
+ // The subscription tier effectively never changes and the billing
+ // boundary moves once a month, so an hour is far tighter than needed
+ // while keeping the status endpoint off the chat path entirely.
+ accountTTL = float64(3600)
)
// chatMaxBodyBytes caps the /v1/chat/completions request body to bound memory
@@ -70,6 +75,24 @@ type modelConfig struct {
Source string `json:"source"`
}
+// chatParameters mirrors the gateway's "parameters" object. The official client
+// builds this map first and always attaches it to the request body; the gateway
+// reads completion caps, thinking control and tool selection from here and never
+// from the top level of the body.
+type chatParameters struct {
+ // MaxTokens caps completion tokens. Always sent, matching the official
+ // client's unconditional max_tokens entry.
+ MaxTokens int `json:"max_tokens"`
+ // MaxThinkingTokens caps thinking tokens. A pointer so that an explicit 0
+ // survives serialization: 0 is how the official client disables thinking.
+ MaxThinkingTokens *int `json:"max_thinking_tokens,omitempty"`
+ // ReasoningEffort is the canonical thinking tier (none/low/medium/high/
+ // xhigh/max) validated against the model's catalog metadata.
+ ReasoningEffort string `json:"reasoning_effort,omitempty"`
+ // ToolChoice carries the OpenAI tool_choice value verbatim.
+ ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
+}
+
type business struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -79,25 +102,28 @@ type business struct {
// ChatRequestBody is the Qoder chat request body sent to the gateway.
// Field order matches baseprompt.json for consistent JSON serialization.
type ChatRequestBody struct {
- RequestID string `json:"request_id"`
- RequestSetID string `json:"request_set_id"`
- ChatRecordID string `json:"chat_record_id"`
- Stream bool `json:"stream"`
- ChatTask string `json:"chat_task"`
- ChatContext chatContext `json:"chat_context"`
- SessionID string `json:"session_id"`
- Source int `json:"source"`
- Version string `json:"version"`
- AliyunUserType string `json:"aliyun_user_type"`
- SessionType string `json:"session_type"`
- AgentID string `json:"agent_id"`
- TaskID string `json:"task_id"`
- ModelConfig modelConfig `json:"model_config"`
- Messages []transform.QoderMessage `json:"messages"`
- Business business `json:"business"`
- Tools json.RawMessage `json:"tools,omitempty"`
- ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
- ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
+ RequestID string `json:"request_id"`
+ RequestSetID string `json:"request_set_id"`
+ ChatRecordID string `json:"chat_record_id"`
+ Stream bool `json:"stream"`
+ ChatTask string `json:"chat_task"`
+ ChatContext chatContext `json:"chat_context"`
+ SessionID string `json:"session_id"`
+ Source int `json:"source"`
+ Version string `json:"version"`
+ AliyunUserType string `json:"aliyun_user_type"`
+ SessionType string `json:"session_type"`
+ AgentID string `json:"agent_id"`
+ TaskID string `json:"task_id"`
+ ModelConfig modelConfig `json:"model_config"`
+ Messages []transform.QoderMessage `json:"messages"`
+ Business business `json:"business"`
+ Parameters chatParameters `json:"parameters"`
+ Tools json.RawMessage `json:"tools,omitempty"`
+ // ParallelToolCalls has no equivalent in the native gateway contract: the
+ // official client only forwards it on the external OpenAI-compatible path.
+ // It is passed through for clients that send it, but the gateway ignores it.
+ ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
}
// newRequestBody creates a ChatRequestBody with hardcoded defaults that
@@ -139,6 +165,9 @@ func newRequestBody() *ChatRequestBody {
Business: business{
Name: "hi",
},
+ Parameters: chatParameters{
+ MaxTokens: models.DefaultMaxOutputTokens,
+ },
}
}
@@ -179,6 +208,12 @@ type OpenAiBridge struct {
catalogTs float64
catalogInflt bool // single-flight: a fetch is already in progress
+ // account caches the /user/status result: the real subscription tier
+ // (which jobToken does not report) and the billing-cycle boundary.
+ accountMu sync.Mutex
+ account *auth.AccountStatus
+ accountTs float64
+
// chatSlots bounds concurrent upstream chat requests per PAT. Exceeding
// the gateway's per-account admission window yields business code 10605
// ("gateway busy") over HTTP 401/403, so we queue locally instead.
@@ -231,19 +266,181 @@ func (b *OpenAiBridge) bootstrapSession(ctx context.Context) error {
if err != nil {
return err
}
- name, _ := jt["name"].(string)
id, _ := jt["id"].(string)
exp, _ := jt["expireTime"]
- log.Printf("[bridge] session for %s (%s) [%s] exp=%v", name, id, b.Region.Name, exp)
- b.applyJobToken(jt)
+ // The jobToken response carries no userType, so the authoritative tier has
+ // to come from /user/status. It must be resolved BEFORE the session is
+ // built because the value is AES-signed into the bearer payload and cannot
+ // be amended afterwards.
+ userType := b.resolveAccountStatus(ctx, id)
+ name, _ := jt["name"].(string)
+ log.Printf("[bridge] session for %s (%s) [%s] exp=%v userType=%s", name, id, b.Region.Name, exp, userType)
+ b.applyJobToken(jt, userType)
b.bootstrapped.Store(true)
return nil
}
-func (b *OpenAiBridge) applyJobToken(jt map[string]interface{}) {
+// resolveAccountStatus fetches /user/status and returns the authoritative
+// subscription tier. On failure it degrades in order: last known tier (even if
+// stale) → empty, letting applyJobToken fall back to the jobToken value and
+// then the historical default.
+//
+// Preferring a stale tier over the default matters on the renewal path: a
+// transient status outage must not downgrade a team account to
+// personal_standard and change the signed bearer payload mid-session.
+func (b *OpenAiBridge) resolveAccountStatus(ctx context.Context, userID string) string {
+ st, err := b.fetchAccountStatus(ctx, userID)
+ if err == nil && st != nil && st.UserType != "" {
+ return st.UserType
+ }
+ if err != nil {
+ log.Printf("[bridge] WARN user/status failed (%v); reusing last known tier", err)
+ }
+ if cached := b.AccountStatus(); cached != nil && cached.UserType != "" {
+ return cached.UserType
+ }
+ return ""
+}
+
+// fetchAccountStatus queries /user/status with a TTL cache. Unlike the chat
+// path it performs no single-flight coordination: callers are the renewal
+// flows (already serialized by refreshMu) and the admin poll.
+func (b *OpenAiBridge) fetchAccountStatus(ctx context.Context, userID string) (*auth.AccountStatus, error) {
+ now := float64(time.Now().Unix())
+ b.accountMu.Lock()
+ if cached := b.account; cached != nil && now-b.accountTs < accountTTL {
+ b.accountMu.Unlock()
+ return cached, nil
+ }
+ b.accountMu.Unlock()
+
+ if userID == "" {
+ return nil, fmt.Errorf("no user id available for user/status")
+ }
+ raw, err := auth.FetchUserStatus(ctx, userID, b.machineID, b.machineToken, b.machineType, b.Region)
+ if err != nil {
+ return nil, err
+ }
+ st, ok := auth.ParseAccountStatus(raw)
+ if !ok {
+ return nil, fmt.Errorf("user/status response shape unexpected")
+ }
+ b.accountMu.Lock()
+ // A successful identity refresh must not erase the last authoritative
+ // quota snapshot if the immediately following OpenAPI call fails. Preserve
+ // only quota-owned fields; plan/tag/org metadata comes from this fresh
+ // gateway response.
+ if previous := b.account; previous != nil && previous.UserQuota != nil {
+ st.NextResetAtMs = previous.NextResetAtMs
+ st.IsQuotaExceeded = previous.IsQuotaExceeded
+ st.TotalUsagePercentage = previous.TotalUsagePercentage
+ st.UserQuota = previous.UserQuota
+ st.AddOnQuota = previous.AddOnQuota
+ st.OrgResourcePackage = previous.OrgResourcePackage
+ }
+ b.account = cloneAccountStatus(&st)
+ b.accountTs = float64(time.Now().Unix())
+ b.accountMu.Unlock()
+ log.Printf("[bridge] account status: userType=%s plan=%s tag=%s nextReset=%s quotaExceeded=%t",
+ st.UserType, st.Plan, st.UserTag, fmtResetTime(st.NextResetAtMs), st.IsQuotaExceeded)
+ return &st, nil
+}
+
+// EnsureAccountStatus returns the subscription state, refreshing both the
+// gateway identity metadata and the authoritative OpenAPI quota snapshot. It
+// bootstraps the session first because OpenAPI uses the securityOauthToken
+// produced by that exchange. Failures preserve the last known snapshot so a
+// temporary account-service outage never breaks the caller's accounting loop.
+func (b *OpenAiBridge) EnsureAccountStatus(ctx context.Context) *auth.AccountStatus {
+ if err := b.EnsureFreshSession(ctx); err != nil {
+ log.Printf("[bridge] WARN account status unavailable (session: %v)", err)
+ return b.AccountStatus()
+ }
+ userID := ""
+ if ident := b.currentIdentity(); ident != nil {
+ userID = ident.Aid
+ }
+ st, err := b.fetchAccountStatus(ctx, userID)
+ if err != nil {
+ log.Printf("[bridge] WARN user/status refresh failed: %v", err)
+ st = b.AccountStatus()
+ }
+
+ quota, err := auth.FetchQuotaUsage(ctx, b.currentSecurityOauth(), b.Region)
+ if err != nil {
+ log.Printf("[bridge] WARN quota/usage refresh failed: %v", err)
+ return st
+ }
+ if st == nil {
+ st = &auth.AccountStatus{}
+ } else {
+ st = cloneAccountStatus(st)
+ }
+ if quota.UserType != "" {
+ st.UserType = quota.UserType
+ }
+ st.NextResetAtMs = quota.ExpiresAtMs
+ st.IsQuotaExceeded = quota.IsQuotaExceeded
+ st.TotalUsagePercentage = quota.TotalUsagePercentage
+ st.UserQuota = quota.UserQuota
+ st.AddOnQuota = quota.AddOnQuota
+ st.OrgResourcePackage = quota.OrgResourcePackage
+
+ b.accountMu.Lock()
+ b.account = cloneAccountStatus(st)
+ b.accountMu.Unlock()
+ log.Printf("[bridge] quota usage: userType=%s used=%.2f total=%.2f remaining=%.2f reset=%s quotaExceeded=%t",
+ st.UserType, st.UserQuota.Used, st.UserQuota.Total, st.UserQuota.Remaining,
+ fmtResetTime(st.NextResetAtMs), st.IsQuotaExceeded)
+ return cloneAccountStatus(st)
+}
+
+// AccountStatus returns the cached subscription state, or nil when it has
+// never been resolved. It performs no network call, so it is safe to invoke
+// from a request handler at any cadence.
+func (b *OpenAiBridge) AccountStatus() *auth.AccountStatus {
+ b.accountMu.Lock()
+ defer b.accountMu.Unlock()
+ return cloneAccountStatus(b.account)
+}
+
+func cloneAccountStatus(st *auth.AccountStatus) *auth.AccountStatus {
+ if st == nil {
+ return nil
+ }
+ cp := *st
+ if st.UserQuota != nil {
+ q := *st.UserQuota
+ cp.UserQuota = &q
+ }
+ if st.AddOnQuota != nil {
+ q := *st.AddOnQuota
+ cp.AddOnQuota = &q
+ }
+ if st.OrgResourcePackage != nil {
+ q := *st.OrgResourcePackage
+ cp.OrgResourcePackage = &q
+ }
+ return &cp
+}
+
+// fmtResetTime renders an epoch-millis reset instant for logs, or "unknown".
+func fmtResetTime(ms int64) string {
+ if ms <= 0 {
+ return "unknown"
+ }
+ return time.UnixMilli(ms).Local().Format("2006-01-02 15:04 MST")
+}
+
+// applyJobToken builds the signed session from a jobToken response. userType
+// is the tier resolved from /user/status; when empty the jobToken value is
+// used, and only then does the historical default apply.
+func (b *OpenAiBridge) applyJobToken(jt map[string]interface{}, userType string) {
name, _ := jt["name"].(string)
id, _ := jt["id"].(string)
- userType, _ := jt["userType"].(string)
+ if userType == "" {
+ userType, _ = jt["userType"].(string)
+ }
if userType == "" {
userType = "personal_standard"
}
@@ -328,6 +525,13 @@ func (b *OpenAiBridge) currentIdentity() *auth.AuthIdentity {
return id
}
+func (b *OpenAiBridge) currentSecurityOauth() string {
+ b.mu.Lock()
+ token := b.securityOauth
+ b.mu.Unlock()
+ return token
+}
+
// doRenew renews the session token via refreshToken.
func (b *OpenAiBridge) doRenew(ctx context.Context, force bool) error {
b.mu.Lock()
@@ -353,7 +557,8 @@ func (b *OpenAiBridge) doRenew(ctx context.Context, force bool) error {
}
log.Printf("[bridge] session %s (exp=%v)", label, jt["expireTime"])
}
- b.applyJobToken(jt)
+ id, _ := jt["id"].(string)
+ b.applyJobToken(jt, b.resolveAccountStatus(ctx, id))
return nil
}
@@ -529,9 +734,18 @@ func (b *OpenAiBridge) HandleChat(ctx context.Context, w http.ResponseWriter, re
body.Stream = true
body.AliyunUserType = identity.UserType
body.ModelConfig.Key = qoderModel
- body.ModelConfig.IsReasoning = true
body.ChatContext.Extra.ModelConfig.Key = qoderModel
- body.ChatContext.Extra.ModelConfig.IsReasoning = true
+
+ // Thinking capability comes from the gateway catalog the way the official
+ // client resolves it (is_reasoning ?? false), instead of being forced on.
+ // A resolved "none" tier overrides both copies further below.
+ reasoningOn := catalog.ReasoningDefault(qoderModel)
+ body.ModelConfig.IsReasoning = reasoningOn
+ body.ChatContext.Extra.ModelConfig.IsReasoning = reasoningOn
+ // Completion cap follows the official precedence: a caller-supplied value
+ // wins, the catalog default is only the fallback. Clamping to the catalog
+ // would silently truncate models whose advertised cap is small.
+ body.Parameters.MaxTokens = resolveMaxTokens(reqBody, catalog.MaxOutputTokens(qoderModel))
body.Business.ID = uuid.New().String()
body.Business.BeginAt = time.Now().UnixMilli()
@@ -574,7 +788,25 @@ func (b *OpenAiBridge) HandleChat(ctx context.Context, w http.ResponseWriter, re
log.Printf("[bridge] multimodal: %d image(s) attached [%s]", imgCount, openaiModel)
}
- log.Printf("[bridge] chat req: prompt_len=%d model=%s", len(prompt), openaiModel)
+ if effort := resolveReasoningEffort(reqBody, catalog.Reasoning[qoderModel]); effort != "" {
+ // The gateway reads the tier from "parameters", never from the top level
+ // of the body. The official client assembles it the same way.
+ body.Parameters.ReasoningEffort = effort
+ if effort == "none" {
+ // Disable thinking explicitly: the client sets is_reasoning=false and
+ // max_thinking_tokens=0 together. The pointer field is what lets an
+ // explicit 0 survive serialization instead of being omitted.
+ zero := 0
+ body.Parameters.MaxThinkingTokens = &zero
+ body.ModelConfig.IsReasoning = false
+ body.ChatContext.Extra.ModelConfig.IsReasoning = false
+ }
+ log.Printf("[bridge] chat req: prompt_len=%d model=%s reasoning_effort=%s is_reasoning=%t max_tokens=%d",
+ len(prompt), openaiModel, effort, body.ModelConfig.IsReasoning, body.Parameters.MaxTokens)
+ } else {
+ log.Printf("[bridge] chat req: prompt_len=%d model=%s reasoning=default(on) is_reasoning=%t max_tokens=%d",
+ len(prompt), openaiModel, body.ModelConfig.IsReasoning, body.Parameters.MaxTokens)
+ }
url := auth.ChatURL(b.Region)
extraHeaders := map[string]string{
@@ -867,6 +1099,7 @@ func MakeChatHandler(resolver BridgeResolver, rec *stats.Recorder) http.HandlerF
CompletionTokens: u.CompletionTokens,
CachedTokens: u.CachedPromptTokens(),
Credits: u.Credits,
+ NonBillable: !u.Billable,
})
}
}
@@ -966,6 +1199,28 @@ func statsModelLabel(reqBody map[string]interface{}, b *OpenAiBridge, ctx contex
return model
}
+// resolveMaxTokens picks the completion cap for the gateway request. A positive
+// integer from the client wins, matching the official client's
+// LS(maxOutputTokens ?? catalogDefault); otherwise the catalog value applies.
+// OpenAI clients may use either max_tokens or the newer max_completion_tokens.
+func resolveMaxTokens(reqBody map[string]interface{}, catalogDefault int) int {
+ for _, key := range []string{"max_tokens", "max_completion_tokens"} {
+ if n, ok := positiveJSONInt(reqBody[key]); ok {
+ return n
+ }
+ }
+ return catalogDefault
+}
+
+// positiveJSONInt parses a decoded JSON number into a positive int.
+func positiveJSONInt(v interface{}) (int, bool) {
+ n, ok := v.(float64)
+ if !ok || n != float64(int(n)) || int(n) <= 0 {
+ return 0, false
+ }
+ return int(n), true
+}
+
func extractMessages(raw interface{}) []map[string]interface{} {
list, ok := raw.([]interface{})
if !ok {
@@ -980,6 +1235,44 @@ func extractMessages(raw interface{}) []map[string]interface{} {
return result
}
+// resolveReasoningEffort validates the OpenAI-style reasoning_effort against
+// the selected model's catalog metadata. Empty means preserve the gateway's
+// existing default reasoning behaviour by omitting the optional field.
+func resolveReasoningEffort(reqBody map[string]interface{}, ri *models.ModelReasoning) string {
+ raw, _ := reqBody["reasoning_effort"].(string)
+ effort := normalizeReasoningEffort(raw)
+ if effort == "" {
+ if strings.TrimSpace(raw) != "" {
+ log.Printf("[bridge] ignoring unsupported reasoning_effort %q; keeping reasoning enabled", raw)
+ }
+ return ""
+ }
+ if ri == nil || !ri.Known {
+ return effort
+ }
+ if effort == "none" && ri.SupportsDisabled {
+ return effort
+ }
+ for _, supported := range ri.Efforts {
+ if effort == supported {
+ return effort
+ }
+ }
+ log.Printf("[bridge] reasoning_effort %q not supported by model; keeping model default", effort)
+ return ""
+}
+
+func normalizeReasoningEffort(value string) string {
+ switch effort := strings.ToLower(strings.TrimSpace(value)); effort {
+ case "none", "low", "medium", "high", "xhigh", "max":
+ return effort
+ case "minimal":
+ return "low"
+ default:
+ return ""
+ }
+}
+
// applyToolConfig applies tool configuration from the client request to the body struct.
func applyToolConfig(body *ChatRequestBody, reqBody map[string]interface{}) bool {
toolsEnabled := false
@@ -990,7 +1283,7 @@ func applyToolConfig(body *ChatRequestBody, reqBody map[string]interface{}) bool
}
if tc, ok := reqBody["tool_choice"]; ok {
b, _ := marshalNoEscape(tc)
- body.ToolChoice = b
+ body.Parameters.ToolChoice = b
}
if ptc, ok := reqBody["parallel_tool_calls"]; ok {
b, _ := marshalNoEscape(ptc)
diff --git a/bridge/bridge_test.go b/bridge/bridge_test.go
index 4c6b7a5..6c43f66 100644
--- a/bridge/bridge_test.go
+++ b/bridge/bridge_test.go
@@ -9,12 +9,14 @@ import (
"net/http"
"net/http/httptest"
"strings"
+ "sync"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"qoder2api/auth"
+ "qoder2api/models"
"qoder2api/stats"
"qoder2api/transform"
)
@@ -204,11 +206,13 @@ func TestNeedsRefreshWithUnknownExpiry(t *testing.T) {
if !b.needsRefresh() {
t.Fatal("fresh bridge must need refresh (no session)")
}
+ // An empty resolved tier falls back to the jobToken value, matching the
+ // pre-user/status behaviour.
b.applyJobToken(map[string]interface{}{
"name": "u", "id": "1", "userType": "personal_standard",
"refreshToken": "r", "securityOauthToken": "s",
"expireTime": nil, // missing expiry
- })
+ }, "")
if b.needsRefresh() {
t.Error("missing expireTime must fall back to TTL-based freshness, not always-refresh")
}
@@ -227,7 +231,7 @@ func TestCurrentIdentitySnapshot(t *testing.T) {
if b.currentIdentity() != nil {
t.Fatal("expected nil identity before bootstrap")
}
- b.applyJobToken(map[string]interface{}{"name": "u", "id": "1", "expireTime": float64(1e15)})
+ b.applyJobToken(map[string]interface{}{"name": "u", "id": "1", "expireTime": float64(1e15)}, "")
id := b.currentIdentity()
if id == nil || id.Name != "u" {
t.Fatalf("expected identity snapshot, got %+v", id)
@@ -243,6 +247,15 @@ func TestCurrentIdentitySnapshot(t *testing.T) {
// region pointing at this test server).
type fakeGateway struct {
chatLines []string // raw SSE lines ("data: {...}")
+
+ mu sync.Mutex
+ chatBody []byte
+}
+
+func (g *fakeGateway) lastChatBody() []byte {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return append([]byte(nil), g.chatBody...)
}
func newFakeBridge(t *testing.T, gw *fakeGateway) *OpenAiBridge {
@@ -254,9 +267,13 @@ func newFakeBridge(t *testing.T, gw *fakeGateway) *OpenAiBridge {
})
mux.HandleFunc("/algo/api/v2/model/list", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
- io.WriteString(w, `{"chat":[{"key":"qmodel_latest","display_name":"Fake-Model","enable":true,"is_vl":false}]}`)
+ io.WriteString(w, `{"chat":[{"key":"qmodel_latest","display_name":"Fake-Model","enable":true,"is_vl":false,"is_reasoning":true,"max_output_tokens":4096,"efforts":["low","medium","xhigh"],"supports_disabled":true}]}`)
})
mux.HandleFunc("/algo/api/v2/service/pro/sse/agent_chat_generation", func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gw.mu.Lock()
+ gw.chatBody = body
+ gw.mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
flusher := w.(http.Flusher)
for _, l := range gw.chatLines {
@@ -808,3 +825,256 @@ func TestChatSlotsSerializeUpstreamCalls(t *testing.T) {
t.Errorf("max concurrent upstream calls = %d, want 1 (default slot limit)", got)
}
}
+
+func TestEnsureAccountStatusMergesAuthoritativeQuota(t *testing.T) {
+ var quotaCalls atomic.Int32
+ mux := http.NewServeMux()
+ mux.HandleFunc("/algo/api/v3/user/jobToken", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, `{"name":"tester","id":"uid1","refreshToken":"rt","securityOauthToken":"sot","expireTime":%d}`, time.Now().Add(6*time.Hour).UnixMilli())
+ })
+ mux.HandleFunc("/algo/api/v3/user/status", func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, `{"id":"uid1","userType":"teams","plan":"PLAN_TIER_TEAM","userTag":"Teams","orgName":"Example Org","nextResetAt":1,"isQuotaExceeded":false}`)
+ })
+ mux.HandleFunc("/api/v2/quota/usage", func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Authorization"); got != "Bearer sot" {
+ t.Errorf("quota Authorization = %q", got)
+ }
+ if quotaCalls.Add(1) > 1 {
+ http.Error(w, "temporary outage", http.StatusServiceUnavailable)
+ return
+ }
+ io.WriteString(w, `{"userId":"uid1","userType":"teams","totalUsagePercentage":0.98,"isQuotaExceeded":true,"expiresAt":1790265600000,"userQuota":{"total":3000,"used":2939,"remaining":61,"percentage":0.98,"unit":"credits"},"orgResourcePackage":{"used":0,"cap":4000,"remaining":0,"percentage":0,"available":false,"unit":"credits"}}`)
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ region := &auth.RegionConfig{Name: "test", AuthBase: srv.URL, ChatBase: srv.URL, OpenAPIBase: srv.URL}
+ b := NewOpenAiBridge("pt-test", region)
+ st := b.EnsureAccountStatus(context.Background())
+ if st == nil || st.UserQuota == nil {
+ t.Fatalf("expected authoritative quota, got %+v", st)
+ }
+ if st.UserQuota.Used != 2939 || st.UserQuota.Remaining != 61 || st.NextResetAtMs != 1790265600000 {
+ t.Errorf("unexpected quota merge: %+v", st)
+ }
+ if !st.IsQuotaExceeded || st.Plan != "PLAN_TIER_TEAM" || st.OrgName != "Example Org" {
+ t.Errorf("gateway metadata and OpenAPI verdict were not merged: %+v", st)
+ }
+ if st.OrgResourcePackage == nil || st.OrgResourcePackage.Cap != 4000 {
+ t.Errorf("organization package missing: %+v", st.OrgResourcePackage)
+ }
+
+ // Force the identity cache stale, then fail the next quota request. A fresh
+ // reduced /user/status response must not erase the last official snapshot.
+ b.accountMu.Lock()
+ b.accountTs = 0
+ b.accountMu.Unlock()
+ st = b.EnsureAccountStatus(context.Background())
+ if st == nil || st.UserQuota == nil || st.UserQuota.Used != 2939 || !st.IsQuotaExceeded {
+ t.Errorf("temporary quota outage erased the last authoritative snapshot: %+v", st)
+ }
+}
+
+func TestHandleChatSendsReasoningEffortToSignedGateway(t *testing.T) {
+ gw := &fakeGateway{chatLines: []string{
+ sseFrame(t, map[string]interface{}{"choices": []interface{}{map[string]interface{}{"delta": map[string]interface{}{"content": "ok"}}}}),
+ "data: [DONE]",
+ }}
+ bridge := newFakeBridge(t, gw)
+
+ err := bridge.HandleChat(context.Background(), httptest.NewRecorder(), map[string]interface{}{
+ "model": "Fake-Model",
+ "messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}},
+ "stream": true,
+ "reasoning_effort": "xhigh",
+ }, nil)
+ if err != nil {
+ t.Fatalf("HandleChat: %v", err)
+ }
+
+ plain, err := auth.Decode(string(gw.lastChatBody()))
+ if err != nil {
+ t.Fatalf("decode signed gateway request: %v", err)
+ }
+ var payload map[string]interface{}
+ if err := json.Unmarshal(plain, &payload); err != nil {
+ t.Fatalf("decode JSON gateway request: %v", err)
+ }
+ if got := payload["reasoning_effort"]; got != nil {
+ t.Errorf("reasoning_effort must not be a top-level body field, got %#v", got)
+ }
+ params, ok := payload["parameters"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("parameters object missing from gateway request: %s", plain)
+ }
+ if got := params["reasoning_effort"]; got != "xhigh" {
+ t.Errorf("parameters.reasoning_effort = %#v, want xhigh", got)
+ }
+ if got, ok := params["max_tokens"].(float64); !ok || got <= 0 {
+ t.Errorf("parameters.max_tokens = %#v, want a positive cap", params["max_tokens"])
+ }
+ if _, present := params["max_thinking_tokens"]; present {
+ t.Errorf("max_thinking_tokens must be omitted for a non-none tier, got %#v", params["max_thinking_tokens"])
+ }
+ if got := payload["model_config"].(map[string]interface{})["key"]; got != "qmodel_latest" {
+ t.Errorf("model_config.key = %#v, want qmodel_latest", got)
+ }
+}
+
+// A "none" tier must disable thinking the way the official client does: set
+// is_reasoning=false on both model_config copies AND send an explicit
+// max_thinking_tokens of 0. Serializing 0 (rather than omitting it) is the
+// whole point, so the pointer field is asserted here.
+func TestHandleChatDisablesReasoningForNoneTier(t *testing.T) {
+ gw := &fakeGateway{chatLines: []string{
+ sseFrame(t, map[string]interface{}{"choices": []interface{}{map[string]interface{}{"delta": map[string]interface{}{"content": "ok"}}}}),
+ "data: [DONE]",
+ }}
+ bridge := newFakeBridge(t, gw)
+
+ err := bridge.HandleChat(context.Background(), httptest.NewRecorder(), map[string]interface{}{
+ "model": "Fake-Model",
+ "messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}},
+ "stream": true,
+ "reasoning_effort": "none",
+ }, nil)
+ if err != nil {
+ t.Fatalf("HandleChat: %v", err)
+ }
+
+ plain, err := auth.Decode(string(gw.lastChatBody()))
+ if err != nil {
+ t.Fatalf("decode signed gateway request: %v", err)
+ }
+ var payload map[string]interface{}
+ if err := json.Unmarshal(plain, &payload); err != nil {
+ t.Fatalf("decode JSON gateway request: %v", err)
+ }
+
+ params := payload["parameters"].(map[string]interface{})
+ if got := params["reasoning_effort"]; got != "none" {
+ t.Errorf("parameters.reasoning_effort = %#v, want none", got)
+ }
+ if got, ok := params["max_thinking_tokens"].(float64); !ok || got != 0 {
+ t.Errorf("parameters.max_thinking_tokens = %#v, want explicit 0", params["max_thinking_tokens"])
+ }
+ if got := payload["model_config"].(map[string]interface{})["is_reasoning"]; got != false {
+ t.Errorf("model_config.is_reasoning = %#v, want false", got)
+ }
+ extra := payload["chat_context"].(map[string]interface{})["extra"].(map[string]interface{})
+ if got := extra["modelConfig"].(map[string]interface{})["is_reasoning"]; got != false {
+ t.Errorf("chat_context.extra.modelConfig.is_reasoning = %#v, want false", got)
+ }
+}
+
+// A tier the model does not support is dropped entirely, and the model keeps
+// whatever reasoning capability the catalog advertised for it.
+func TestHandleChatUnsupportedTierKeepsCatalogReasoning(t *testing.T) {
+ gw := &fakeGateway{chatLines: []string{
+ sseFrame(t, map[string]interface{}{"choices": []interface{}{map[string]interface{}{"delta": map[string]interface{}{"content": "ok"}}}}),
+ "data: [DONE]",
+ }}
+ bridge := newFakeBridge(t, gw)
+
+ err := bridge.HandleChat(context.Background(), httptest.NewRecorder(), map[string]interface{}{
+ "model": "Fake-Model",
+ "messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}},
+ "stream": true,
+ "reasoning_effort": "high",
+ }, nil)
+ if err != nil {
+ t.Fatalf("HandleChat: %v", err)
+ }
+
+ plain, err := auth.Decode(string(gw.lastChatBody()))
+ if err != nil {
+ t.Fatalf("decode signed gateway request: %v", err)
+ }
+ var payload map[string]interface{}
+ if err := json.Unmarshal(plain, &payload); err != nil {
+ t.Fatalf("decode JSON gateway request: %v", err)
+ }
+
+ params := payload["parameters"].(map[string]interface{})
+ if got, present := params["reasoning_effort"]; present {
+ t.Errorf("unsupported tier must be omitted, got %#v", got)
+ }
+ if got := payload["model_config"].(map[string]interface{})["is_reasoning"]; got != true {
+ t.Errorf("model_config.is_reasoning = %#v, want true (catalog capability preserved)", got)
+ }
+}
+
+func TestResolveReasoningEffortRejectsUnsupportedModelTier(t *testing.T) {
+ qwen := &models.ModelReasoning{Efforts: []string{"low", "medium", "xhigh"}, SupportsDisabled: true, Known: true}
+ if got := resolveReasoningEffort(map[string]interface{}{"reasoning_effort": "high"}, qwen); got != "" {
+ t.Errorf("unsupported tier = %q, want omitted", got)
+ }
+ if got := resolveReasoningEffort(map[string]interface{}{"reasoning_effort": "none"}, qwen); got != "none" {
+ t.Errorf("supported disabled tier = %q, want none", got)
+ }
+}
+
+// The caller's cap must win over the catalog default, otherwise models with a
+// small advertised cap (the built-in BYOK table lists one at 2048) would have
+// their responses silently truncated.
+func TestResolveMaxTokensPrefersCallerValue(t *testing.T) {
+ const catalogDefault = 2048
+
+ cases := []struct {
+ name string
+ reqBody map[string]interface{}
+ want int
+ }{
+ {"caller cap wins over smaller catalog cap",
+ map[string]interface{}{"max_tokens": float64(16000)}, 16000},
+ {"newer max_completion_tokens is honored",
+ map[string]interface{}{"max_completion_tokens": float64(9000)}, 9000},
+ {"max_tokens takes precedence over max_completion_tokens",
+ map[string]interface{}{"max_tokens": float64(4096), "max_completion_tokens": float64(9000)}, 4096},
+ {"absent falls back to catalog", map[string]interface{}{}, catalogDefault},
+ {"zero is not a usable cap", map[string]interface{}{"max_tokens": float64(0)}, catalogDefault},
+ {"negative is not a usable cap", map[string]interface{}{"max_tokens": float64(-5)}, catalogDefault},
+ {"fractional is not a usable cap", map[string]interface{}{"max_tokens": 100.5}, catalogDefault},
+ {"string is not a usable cap", map[string]interface{}{"max_tokens": "8000"}, catalogDefault},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := resolveMaxTokens(tc.reqBody, catalogDefault); got != tc.want {
+ t.Errorf("resolveMaxTokens(%v, %d) = %d, want %d", tc.reqBody, catalogDefault, got, tc.want)
+ }
+ })
+ }
+}
+
+// The resolved cap must reach the signed gateway body inside "parameters".
+func TestHandleChatForwardsCallerMaxTokens(t *testing.T) {
+ gw := &fakeGateway{chatLines: []string{
+ sseFrame(t, map[string]interface{}{"choices": []interface{}{map[string]interface{}{"delta": map[string]interface{}{"content": "ok"}}}}),
+ "data: [DONE]",
+ }}
+ bridge := newFakeBridge(t, gw)
+
+ err := bridge.HandleChat(context.Background(), httptest.NewRecorder(), map[string]interface{}{
+ "model": "Fake-Model",
+ "messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}},
+ "stream": true,
+ "max_tokens": float64(16000),
+ }, nil)
+ if err != nil {
+ t.Fatalf("HandleChat: %v", err)
+ }
+
+ plain, err := auth.Decode(string(gw.lastChatBody()))
+ if err != nil {
+ t.Fatalf("decode signed gateway request: %v", err)
+ }
+ var payload map[string]interface{}
+ if err := json.Unmarshal(plain, &payload); err != nil {
+ t.Fatalf("decode JSON gateway request: %v", err)
+ }
+ params := payload["parameters"].(map[string]interface{})
+ if got := params["max_tokens"].(float64); got != 16000 {
+ t.Errorf("parameters.max_tokens = %v, want 16000 (catalog cap is 4096)", got)
+ }
+}
diff --git a/main.go b/main.go
index 057ed0e..236bef5 100644
--- a/main.go
+++ b/main.go
@@ -61,6 +61,10 @@ func (p *bridgeProvider) currentBridge() *bridge.OpenAiBridge {
return p.bridge
}
+// accountRefreshCadence keeps the authoritative allowance reasonably fresh
+// without coupling the admin panel's 15-second poll to an upstream request.
+const accountRefreshCadence = time.Minute
+
// healthHandler serves an unauthenticated liveness/readiness probe. It is
// deliberately cheap (no upstream calls, no session bootstrap): the body
// carries readiness details (PAT configured, effective stream timeouts)
@@ -166,6 +170,70 @@ func main() {
return catalog.Keys()
}
+ // Subscription account loop: refreshes identity metadata from /user/status
+ // and authoritative allowance totals from OpenAPI /api/v2/quota/usage out
+ // of band. The admin panel's 15-second poll remains memory-only.
+ applyAccount := func(ctx context.Context) {
+ b := provider.currentBridge()
+ if b == nil {
+ return // no PAT configured yet
+ }
+ st := b.EnsureAccountStatus(ctx)
+ if st == nil {
+ return // upstream unreachable: keep the last known state
+ }
+ rec.SetBillingCycle(st.NextResetAtMs)
+ account := &stats.Account{
+ Plan: st.Plan,
+ Tag: st.UserTag,
+ OrgName: st.OrgName,
+ IsQuotaExceeded: st.IsQuotaExceeded,
+ TotalUsagePercentage: st.TotalUsagePercentage,
+ }
+ if st.UserQuota != nil {
+ account.UserQuota = &stats.Quota{
+ Total: st.UserQuota.Total, Used: st.UserQuota.Used,
+ Remaining: st.UserQuota.Remaining, Percentage: st.UserQuota.Percentage,
+ Unit: st.UserQuota.Unit, DetailURL: st.UserQuota.DetailURL,
+ }
+ }
+ if st.AddOnQuota != nil {
+ account.AddOnQuota = &stats.Quota{
+ Total: st.AddOnQuota.Total, Used: st.AddOnQuota.Used,
+ Remaining: st.AddOnQuota.Remaining, Percentage: st.AddOnQuota.Percentage,
+ Unit: st.AddOnQuota.Unit, DetailURL: st.AddOnQuota.DetailURL,
+ }
+ }
+ if st.OrgResourcePackage != nil {
+ account.OrgResourcePackage = &stats.OrgResourcePackage{
+ Used: st.OrgResourcePackage.Used, Cap: st.OrgResourcePackage.Cap,
+ Remaining: st.OrgResourcePackage.Remaining, Percentage: st.OrgResourcePackage.Percentage,
+ Available: st.OrgResourcePackage.Available, Unit: st.OrgResourcePackage.Unit,
+ }
+ }
+ rec.SetAccount(account)
+ }
+ go func() {
+ // Fetch once immediately so the panel is populated from the start
+ // rather than after the first tick.
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ applyAccount(ctx)
+ cancel()
+
+ ticker := time.NewTicker(accountRefreshCadence)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ applyAccount(ctx)
+ cancel()
+ case <-statsStop:
+ return
+ }
+ }
+ }()
+
// Initialize admin
adminInst := admin.New(st, modelFetcher, rec)
diff --git a/models/models.go b/models/models.go
index aa34924..48b70fb 100644
--- a/models/models.go
+++ b/models/models.go
@@ -3,6 +3,7 @@ package models
import (
"sort"
+ "strconv"
"strings"
)
@@ -28,31 +29,31 @@ func enableFlag(v interface{}) bool {
// Notable vs v5: Qwen3.8-Max graduated from preview (qmodel_preview ->
// qmodel_38max, renamed without "-Preview"); GLM-5.3 (gmodel) added.
var DefaultModelMap = map[string]string{
- "Qwen3.8-Max": "qmodel_38max",
- "Qwen3.7-Max": "qmodel_latest",
- "Qwen3.7-Plus": "qmodel",
- "Qwen3.6-Flash": "q36fmodel",
- "DeepSeek-V4-Pro": "dmodel",
+ "Qwen3.8-Max": "qmodel_38max",
+ "Qwen3.7-Max": "qmodel_latest",
+ "Qwen3.7-Plus": "qmodel",
+ "Qwen3.6-Flash": "q36fmodel",
+ "DeepSeek-V4-Pro": "dmodel",
"DeepSeek-V4-Flash": "dfmodel",
- "GLM-5.3": "gmodel",
- "GLM-5.2": "gm51model",
- "Kimi-K2.7-Code": "kmodel",
- "MiniMax-M2.7": "mmodel",
+ "GLM-5.3": "gmodel",
+ "GLM-5.2": "gm51model",
+ "Kimi-K2.7-Code": "kmodel",
+ "MiniMax-M2.7": "mmodel",
}
// DefaultVisionModels mirrors the gateway's is_vl metadata for the fallback
// catalog (display_name). NOTE: the gateway's is_vl flags have proven
// unreliable — do not treat this as an authoritative capability matrix.
var DefaultVisionModels = map[string]bool{
- "Qwen3.8-Max": true,
- "Qwen3.7-Max": true,
- "Qwen3.7-Plus": true,
- "Qwen3.6-Flash": true,
- "DeepSeek-V4-Pro": true,
+ "Qwen3.8-Max": true,
+ "Qwen3.7-Max": true,
+ "Qwen3.7-Plus": true,
+ "Qwen3.6-Flash": true,
+ "DeepSeek-V4-Pro": true,
"DeepSeek-V4-Flash": true,
- "GLM-5.3": true,
- "GLM-5.2": true,
- "Kimi-K2.7-Code": true,
+ "GLM-5.3": true,
+ "GLM-5.2": true,
+ "Kimi-K2.7-Code": true,
}
// PreferredDefaultKey is the default model key when model param is None/empty.
@@ -61,11 +62,79 @@ const PreferredDefaultKey = "qmodel_latest"
// DefaultScene is the catalog scene to extract.
const DefaultScene = "chat"
+// DefaultMaxOutputTokens is the completion-token cap the official client falls
+// back to whenever a catalog entry carries no usable max_output_tokens. It
+// mirrors the client's LS() coercion, which returns 32000 for any value that is
+// not a positive safe integer.
+const DefaultMaxOutputTokens = 32000
+
// ModelCatalog holds display_name → qoder key mapping and capability metadata.
type ModelCatalog struct {
ModelMap map[string]string // display_name -> key
VisionModels map[string]bool // display_name set
DefaultName string // used when model param is empty
+
+ // Reasoning carries the gateway's per-model thinking-effort metadata,
+ // keyed by the qoder internal key. Entries exist only for models whose
+ // catalog record carried effort metadata; a missing entry means "unknown",
+ // and callers fall back to the global effort vocabulary.
+ Reasoning map[string]*ModelReasoning
+
+ // Caps carries the per-model limits the official client reads from the
+ // catalog before building a gateway request, keyed by the qoder internal
+ // key. A missing entry means the catalog was unavailable, and callers fall
+ // back to their own defaults.
+ Caps map[string]*ModelCaps
+}
+
+// ModelCaps mirrors the limits of a model/list entry that the official client
+// resolves in oJI before assembling the request body.
+type ModelCaps struct {
+ // IsReasoning reports whether the gateway says this model can think. The
+ // official client defaults a missing field to false (T?.is_reasoning ?? !1).
+ IsReasoning bool
+ // MaxOutputTokens is the default completion cap, already normalized to
+ // DefaultMaxOutputTokens when the catalog value was absent or unusable.
+ MaxOutputTokens int
+}
+
+// flagValue coerces an optional boolean catalog field. Unlike enableFlag it
+// treats a missing value as false, matching the official client's defaults for
+// capability flags such as is_reasoning.
+func flagValue(v interface{}) bool {
+ if v == nil {
+ return false
+ }
+ return enableFlag(v)
+}
+
+// positiveInt parses a catalog numeric field that may arrive as a JSON number
+// or a numeric string, mirroring the official client's LS() coercion.
+func positiveInt(v interface{}) (int, bool) {
+ switch x := v.(type) {
+ case float64:
+ if n := int(x); float64(n) == x && n > 0 {
+ return n, true
+ }
+ case int:
+ if x > 0 {
+ return x, true
+ }
+ case string:
+ if n, err := strconv.Atoi(strings.TrimSpace(x)); err == nil && n > 0 {
+ return n, true
+ }
+ }
+ return 0, false
+}
+
+// ModelReasoning mirrors the reasoning-related fields of a model/list entry.
+// The official client derives the exact same information before deciding
+// whether a requested effort may be forwarded to the gateway.
+type ModelReasoning struct {
+ Efforts []string // canonical efforts the model accepts (lowercased)
+ SupportsDisabled bool // gateway allows switching thinking off entirely
+ Known bool // true when any effort metadata was present
}
// Keys returns sorted display names for deterministic output.
@@ -83,6 +152,26 @@ func (c *ModelCatalog) GetKey(displayName string) string {
return c.ModelMap[displayName]
}
+// MaxOutputTokens returns the gateway's default completion cap for a qoder key,
+// falling back to DefaultMaxOutputTokens when the catalog carries no entry.
+func (c *ModelCatalog) MaxOutputTokens(qoderKey string) int {
+ if caps, ok := c.Caps[qoderKey]; ok && caps.MaxOutputTokens > 0 {
+ return caps.MaxOutputTokens
+ }
+ return DefaultMaxOutputTokens
+}
+
+// ReasoningDefault reports whether the gateway says a model can think. A catalog
+// with no caps entry for the key reports true, preserving the bridge's
+// long-standing always-on behaviour when the dynamic catalog is unavailable and
+// the gateway's own is_reasoning flag cannot be consulted.
+func (c *ModelCatalog) ReasoningDefault(qoderKey string) bool {
+ if caps, ok := c.Caps[qoderKey]; ok {
+ return caps.IsReasoning
+ }
+ return true
+}
+
// DefaultCatalog returns the built-in fallback catalog.
func DefaultCatalog() *ModelCatalog {
modelMap := make(map[string]string, len(DefaultModelMap))
@@ -120,6 +209,8 @@ func ExtractCatalog(raw map[string]interface{}) *ModelCatalog {
}
modelMap := map[string]string{}
vision := map[string]bool{}
+ reasoning := map[string]*ModelReasoning{}
+ caps := map[string]*ModelCaps{}
for _, item := range sceneList {
m, ok := item.(map[string]interface{})
if !ok {
@@ -139,6 +230,17 @@ func ExtractCatalog(raw map[string]interface{}) *ModelCatalog {
if isVL, ok := m["is_vl"].(bool); ok && isVL {
vision[name] = true
}
+ if ri := parseReasoningMeta(m); ri != nil {
+ reasoning[key] = ri
+ }
+ maxOut, ok := positiveInt(m["max_output_tokens"])
+ if !ok {
+ maxOut = DefaultMaxOutputTokens
+ }
+ caps[key] = &ModelCaps{
+ IsReasoning: flagValue(m["is_reasoning"]),
+ MaxOutputTokens: maxOut,
+ }
}
if len(modelMap) == 0 {
return nil
@@ -146,10 +248,85 @@ func ExtractCatalog(raw map[string]interface{}) *ModelCatalog {
return &ModelCatalog{
ModelMap: modelMap,
VisionModels: vision,
+ Reasoning: reasoning,
+ Caps: caps,
DefaultName: nameForKey(modelMap, PreferredDefaultKey),
}
}
+// parseReasoningMeta extracts the per-model thinking-effort metadata from a
+// catalog entry. It returns nil when the entry carries no usable effort
+// information at all, letting callers fall back to the global vocabulary.
+//
+// The gateway serializes "efforts" in several shapes (array of strings,
+// comma/space separated string, or an object map keyed by effort); all are
+// normalized here the same way the official client normalizes them.
+func parseReasoningMeta(m map[string]interface{}) *ModelReasoning {
+ effortsRaw, hasEfforts := m["efforts"]
+ efforts := normalizeEfforts(effortsRaw)
+ supportsDisabled := false
+ if v, ok := m["supports_disabled"]; ok && enableFlag(v) {
+ supportsDisabled = true
+ }
+ if !hasEfforts && !supportsDisabled {
+ return nil
+ }
+ return &ModelReasoning{
+ Efforts: efforts,
+ SupportsDisabled: supportsDisabled,
+ Known: true,
+ }
+}
+
+// normalizeEfforts coerces any supported "efforts" shape into the canonical
+// lowercase vocabulary (none/low/medium/high/xhigh/max), preserving order and
+// dropping duplicates or unrecognized values. "off"/"disabled" are folded
+// into "none", matching the official client's normalization.
+func normalizeEfforts(v interface{}) []string {
+ var raw []string
+ switch x := v.(type) {
+ case []interface{}:
+ for _, item := range x {
+ if s, ok := item.(string); ok {
+ raw = append(raw, s)
+ }
+ }
+ case string:
+ for _, field := range strings.FieldsFunc(x, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) {
+ raw = append(raw, field)
+ }
+ case map[string]interface{}:
+ for k := range x {
+ raw = append(raw, k)
+ }
+ }
+ seen := map[string]bool{}
+ out := make([]string, 0, len(raw))
+ for _, s := range raw {
+ e := canonicalEffort(s)
+ if e == "" || seen[e] {
+ continue
+ }
+ seen[e] = true
+ out = append(out, e)
+ }
+ return out
+}
+
+// canonicalEffort validates a single effort token against the gateway's
+// vocabulary, folding the client-side aliases "off"/"disabled" into "none".
+func canonicalEffort(s string) string {
+ e := strings.ToLower(strings.TrimSpace(s))
+ switch e {
+ case "off", "disabled":
+ return "none"
+ case "none", "low", "medium", "high", "xhigh", "max":
+ return e
+ default:
+ return ""
+ }
+}
+
// ResolveModel resolves a model name to (display_name, qoder_key).
// If model is empty, uses the catalog default. Returns error if not found.
func ResolveModel(model string, catalog *ModelCatalog) (string, string, error) {
diff --git a/models/models_test.go b/models/models_test.go
index bff41af..2b57fdc 100644
--- a/models/models_test.go
+++ b/models/models_test.go
@@ -111,3 +111,181 @@ func TestFallbackDefaultPrefersCheapTier(t *testing.T) {
t.Errorf("expected deterministic A-Lite, got %q", got)
}
}
+
+// --- Per-model reasoning metadata parsing (efforts / supports_disabled) ---
+
+func TestExtractCatalogParsesReasoningMetadata(t *testing.T) {
+ raw := map[string]interface{}{
+ "chat": []interface{}{
+ // Array shape with aliases and junk entries.
+ map[string]interface{}{"key": "a", "display_name": "ModelA", "enable": true,
+ "efforts": []interface{}{"low", " Medium ", "xhigh", "off", "turbo", 42},
+ "supports_disabled": true},
+ // Comma/space separated string shape.
+ map[string]interface{}{"key": "b", "display_name": "ModelB", "enable": true,
+ "efforts": "high, max"},
+ // Object-map shape (keys are the efforts, values are descriptors).
+ map[string]interface{}{"key": "c", "display_name": "ModelC", "enable": true,
+ "efforts": map[string]interface{}{"low": map[string]interface{}{}, "xhigh": map[string]interface{}{"is_default": true}}},
+ // No reasoning metadata at all: must be absent from the map.
+ map[string]interface{}{"key": "d", "display_name": "ModelD", "enable": true},
+ // Only supports_disabled: on/off switch without effort tiers.
+ map[string]interface{}{"key": "e", "display_name": "ModelE", "enable": true,
+ "supports_disabled": "true"},
+ },
+ }
+ cat := ExtractCatalog(raw)
+ if cat == nil {
+ t.Fatal("expected non-nil catalog")
+ }
+
+ a := cat.Reasoning["a"]
+ if a == nil {
+ t.Fatal("ModelA reasoning metadata missing")
+ }
+ wantA := []string{"low", "medium", "xhigh", "none"}
+ if len(a.Efforts) != len(wantA) {
+ t.Fatalf("ModelA efforts = %v, want %v", a.Efforts, wantA)
+ }
+ for i, e := range wantA {
+ if a.Efforts[i] != e {
+ t.Errorf("ModelA efforts[%d] = %q, want %q", i, a.Efforts[i], e)
+ }
+ }
+ if !a.SupportsDisabled {
+ t.Error("ModelA supports_disabled should be true")
+ }
+ if !a.Known {
+ t.Error("ModelA should be Known")
+ }
+
+ b := cat.Reasoning["b"]
+ if b == nil {
+ t.Fatal("ModelB reasoning metadata missing")
+ }
+ if len(b.Efforts) != 2 || b.Efforts[0] != "high" || b.Efforts[1] != "max" {
+ t.Errorf("ModelB efforts = %v, want [high max]", b.Efforts)
+ }
+ if b.SupportsDisabled {
+ t.Error("ModelB supports_disabled should default to false")
+ }
+
+ c := cat.Reasoning["c"]
+ if c == nil {
+ t.Fatal("ModelC reasoning metadata missing")
+ }
+ if len(c.Efforts) != 2 || c.Efforts[0] != "low" || c.Efforts[1] != "xhigh" {
+ t.Errorf("ModelC efforts = %v, want [low xhigh]", c.Efforts)
+ }
+
+ if _, ok := cat.Reasoning["d"]; ok {
+ t.Error("ModelD without metadata should not appear in Reasoning")
+ }
+
+ e := cat.Reasoning["e"]
+ if e == nil {
+ t.Fatal("ModelE reasoning metadata missing")
+ }
+ if len(e.Efforts) != 0 {
+ t.Errorf("ModelE efforts = %v, want empty", e.Efforts)
+ }
+ if !e.SupportsDisabled {
+ t.Error("ModelE supports_disabled should be coerced from \"true\"")
+ }
+}
+
+func TestDefaultCatalogHasNoReasoningMetadata(t *testing.T) {
+ cat := DefaultCatalog()
+ if cat.Reasoning != nil {
+ t.Errorf("fallback catalog must not claim per-model effort knowledge, got %v", cat.Reasoning)
+ }
+}
+
+// The bridge reads is_reasoning and max_output_tokens out of the catalog the
+// same way the official client does, so both must survive parsing, including
+// their fallbacks for missing or malformed values.
+func TestExtractCatalogParsesModelCaps(t *testing.T) {
+ raw := map[string]interface{}{
+ "chat": []interface{}{
+ // Reasoning model with an explicit cap.
+ map[string]interface{}{"key": "a", "display_name": "ModelA", "enable": true,
+ "is_reasoning": true, "max_output_tokens": 8000},
+ // Non-reasoning model; cap arrives as a numeric string.
+ map[string]interface{}{"key": "b", "display_name": "ModelB", "enable": true,
+ "is_reasoning": false, "max_output_tokens": "6000"},
+ // No caps at all: is_reasoning defaults to false, cap to the fallback.
+ map[string]interface{}{"key": "c", "display_name": "ModelC", "enable": true},
+ // Unusable cap must fall back, not leak a zero that would truncate output.
+ map[string]interface{}{"key": "d", "display_name": "ModelD", "enable": true,
+ "is_reasoning": true, "max_output_tokens": 0},
+ },
+ }
+ cat := ExtractCatalog(raw)
+ if cat == nil {
+ t.Fatal("expected non-nil catalog")
+ }
+
+ a := cat.Caps["a"]
+ if a == nil {
+ t.Fatal("ModelA caps missing")
+ }
+ if !a.IsReasoning {
+ t.Error("ModelA is_reasoning should be true")
+ }
+ if a.MaxOutputTokens != 8000 {
+ t.Errorf("ModelA max_output_tokens = %d, want 8000", a.MaxOutputTokens)
+ }
+
+ b := cat.Caps["b"]
+ if b == nil {
+ t.Fatal("ModelB caps missing")
+ }
+ if b.IsReasoning {
+ t.Error("ModelB is_reasoning should be false")
+ }
+ if b.MaxOutputTokens != 6000 {
+ t.Errorf("ModelB max_output_tokens = %d, want 6000 (parsed from string)", b.MaxOutputTokens)
+ }
+
+ c := cat.Caps["c"]
+ if c == nil {
+ t.Fatal("ModelC caps missing")
+ }
+ if c.IsReasoning {
+ t.Error("ModelC is_reasoning should default to false, matching the official client")
+ }
+ if c.MaxOutputTokens != DefaultMaxOutputTokens {
+ t.Errorf("ModelC max_output_tokens = %d, want %d", c.MaxOutputTokens, DefaultMaxOutputTokens)
+ }
+
+ if got := cat.MaxOutputTokens("d"); got != DefaultMaxOutputTokens {
+ t.Errorf("MaxOutputTokens(d) = %d, want %d for an unusable cap", got, DefaultMaxOutputTokens)
+ }
+ if got := cat.MaxOutputTokens("missing"); got != DefaultMaxOutputTokens {
+ t.Errorf("MaxOutputTokens(missing) = %d, want %d", got, DefaultMaxOutputTokens)
+ }
+ if got := cat.ReasoningDefault("a"); !got {
+ t.Error("ReasoningDefault(a) = false, want true")
+ }
+ if got := cat.ReasoningDefault("c"); got {
+ t.Error("ReasoningDefault(c) = true, want false")
+ }
+ // A model absent from caps must not silently lose thinking; the bridge only
+ // reaches this branch when the dynamic catalog is unavailable.
+ if got := cat.ReasoningDefault("missing"); !got {
+ t.Error("ReasoningDefault(missing) = false, want true (preserve always-on fallback)")
+ }
+}
+
+func TestDefaultCatalogCapsFallBack(t *testing.T) {
+ cat := DefaultCatalog()
+ if cat.Caps != nil {
+ t.Errorf("fallback catalog must not claim per-model caps, got %v", cat.Caps)
+ }
+ if got := cat.MaxOutputTokens(PreferredDefaultKey); got != DefaultMaxOutputTokens {
+ t.Errorf("MaxOutputTokens = %d, want %d", got, DefaultMaxOutputTokens)
+ }
+ if got := cat.ReasoningDefault(PreferredDefaultKey); !got {
+ t.Error("ReasoningDefault = false, want true when the catalog carries no caps")
+ }
+}
diff --git a/stats/stats.go b/stats/stats.go
index b404ad8..c94d629 100644
--- a/stats/stats.go
+++ b/stats/stats.go
@@ -30,18 +30,71 @@ type HourStat struct {
Failed int64 `json:"failed"`
}
+// Account is the subscription metadata reported by the gateway's
+// /user/status endpoint. It is refreshed out of band (see main's account
+// loop) and persisted alongside the counters so the admin panel can render it
+// from memory instead of making a network call on every poll.
+type Account struct {
+ // Plan is the raw plan identifier ("PLAN_TIER_TEAM", ...).
+ Plan string `json:"plan,omitempty"`
+ // Tag is the human-facing plan label ("Teams", ...).
+ Tag string `json:"tag,omitempty"`
+ // OrgName identifies the organization that owns a shared resource package.
+ OrgName string `json:"org_name,omitempty"`
+ // IsQuotaExceeded is the OpenAPI verdict used by the official client.
+ IsQuotaExceeded bool `json:"is_quota_exceeded"`
+ // TotalUsagePercentage is the official aggregate ratio in [0,1].
+ TotalUsagePercentage float64 `json:"total_usage_percentage"`
+ UserQuota *Quota `json:"user_quota,omitempty"`
+ AddOnQuota *Quota `json:"add_on_quota,omitempty"`
+ OrgResourcePackage *OrgResourcePackage `json:"org_resource_package,omitempty"`
+}
+
+// Quota is a cycle-scoped credit allowance reported by Qoder OpenAPI.
+type Quota struct {
+ Total float64 `json:"total"`
+ Used float64 `json:"used"`
+ Remaining float64 `json:"remaining"`
+ Percentage float64 `json:"percentage"`
+ Unit string `json:"unit"`
+ DetailURL string `json:"detail_url,omitempty"`
+}
+
+// OrgResourcePackage is the shared organization credit pool.
+type OrgResourcePackage struct {
+ Used float64 `json:"used"`
+ Cap float64 `json:"cap"`
+ Remaining float64 `json:"remaining"`
+ Percentage float64 `json:"percentage"`
+ Available bool `json:"available"`
+ Unit string `json:"unit"`
+}
+
// Data is the persisted stats snapshot.
type Data struct {
Total int64 `json:"total"`
Success int64 `json:"success"`
Failed int64 `json:"failed"`
// Token / billing totals aggregated from the gateway usage frames.
- PromptTokens int64 `json:"prompt_tokens"`
- CompletionTokens int64 `json:"completion_tokens"`
- CachedTokens int64 `json:"cached_tokens"`
- Credits float64 `json:"credits"`
- ByModel map[string]*ModelStat `json:"by_model,omitempty"`
- Hourly map[string]*HourStat `json:"hourly,omitempty"`
+ PromptTokens int64 `json:"prompt_tokens"`
+ CompletionTokens int64 `json:"completion_tokens"`
+ CachedTokens int64 `json:"cached_tokens"`
+ Credits float64 `json:"credits"`
+ // Billing-cycle scope. The gateway refreshes the subscription allowance
+ // monthly, so the lifetime Credits total above says nothing about what is
+ // left this cycle. These fields make the displayed number cycle-relative.
+ //
+ // NextResetMs is the subscription refresh instant (epoch millis) as
+ // reported by /user/status; CycleStartMs is the start of the cycle that
+ // contains it; CycleCredits is credits consumed since CycleStartMs and is
+ // reset automatically when the cycle rolls over. All three stay 0 when the
+ // account status has never been resolved.
+ NextResetMs int64 `json:"next_reset_ms,omitempty"`
+ CycleStartMs int64 `json:"cycle_start_ms,omitempty"`
+ CycleCredits float64 `json:"cycle_credits,omitempty"`
+ Account *Account `json:"account,omitempty"`
+ ByModel map[string]*ModelStat `json:"by_model,omitempty"`
+ Hourly map[string]*HourStat `json:"hourly,omitempty"`
}
// ModelRow is a per-model report row served to the admin UI.
@@ -69,12 +122,19 @@ type Report struct {
Failed int64 `json:"failed"`
SuccessRate float64 `json:"success_rate"`
// Token / billing totals.
- PromptTokens int64 `json:"prompt_tokens"`
- CompletionTokens int64 `json:"completion_tokens"`
- CachedTokens int64 `json:"cached_tokens"`
- Credits float64 `json:"credits"`
- ByModel []ModelRow `json:"by_model"`
- Hourly []HourRow `json:"hourly"`
+ PromptTokens int64 `json:"prompt_tokens"`
+ CompletionTokens int64 `json:"completion_tokens"`
+ CachedTokens int64 `json:"cached_tokens"`
+ Credits float64 `json:"credits"`
+ // Billing-cycle view of the credits above (all zero when the account
+ // status is unknown).
+ CycleCredits float64 `json:"cycle_credits"`
+ CycleStartMs int64 `json:"cycle_start_ms"`
+ NextResetMs int64 `json:"next_reset_ms"`
+ // Account is nil when the subscription state has never been resolved.
+ Account *Account `json:"account,omitempty"`
+ ByModel []ModelRow `json:"by_model"`
+ Hourly []HourRow `json:"hourly"`
}
// Persister persists the stats data (implemented by store.Store).
@@ -136,9 +196,13 @@ func (d *Data) Clone() *Data {
CompletionTokens: d.CompletionTokens,
CachedTokens: d.CachedTokens,
Credits: d.Credits,
+ NextResetMs: d.NextResetMs,
+ CycleStartMs: d.CycleStartMs,
+ CycleCredits: d.CycleCredits,
ByModel: make(map[string]*ModelStat, len(d.ByModel)),
Hourly: make(map[string]*HourStat, len(d.Hourly)),
}
+ cp.Account = cloneAccount(d.Account)
for k, v := range d.ByModel {
cp.ByModel[k] = &ModelStat{Model: v.Model, Total: v.Total, Success: v.Success, Failed: v.Failed}
}
@@ -190,6 +254,10 @@ func (r *Recorder) Record(model string, ok bool) {
// RecordUsage adds token/billing totals extracted from a gateway usage frame.
// Called once per successful request when the upstream supplies usage data;
// missing frames (error-interrupted streams) simply skip this call.
+//
+// Tokens are counted for every frame, but credits only for billable ones: a
+// frame the gateway flags billable=false was not charged to the subscription,
+// so including it would overstate consumption against the cycle allowance.
func (r *Recorder) RecordUsage(u *Usage) {
if u == nil {
return
@@ -200,7 +268,103 @@ func (r *Recorder) RecordUsage(u *Usage) {
r.data.PromptTokens += int64(u.PromptTokens)
r.data.CompletionTokens += int64(u.CompletionTokens)
r.data.CachedTokens += int64(u.CachedTokens)
+ if u.NonBillable {
+ return
+ }
r.data.Credits += u.Credits
+ r.rollCycleLocked(time.Now().UnixMilli())
+ r.data.CycleCredits += u.Credits
+}
+
+// SetBillingCycle records the subscription boundary reported by /user/status.
+//
+// nextResetMs is the refresh instant; the cycle start is derived by stepping
+// back one calendar month, which matches the gateway's monthly refresh
+// exactly (a fixed 30-day period would drift, e.g. reporting a cycle start of
+// Aug 26 for a Sep 25 reset).
+//
+// Passing 0 (status unavailable) leaves the existing cycle intact rather than
+// discarding accounting that may already be correct. A boundary that still
+// falls inside the cycle currently being tracked refines it without zeroing,
+// so repeated polls never lose accumulated credits.
+func (r *Recorder) SetBillingCycle(nextResetMs int64) {
+ if nextResetMs <= 0 {
+ return
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.data.NextResetMs == nextResetMs {
+ return
+ }
+ startMs := addMonthsMs(nextResetMs, -1)
+ if startMs != r.data.CycleStartMs {
+ // A genuinely different cycle: the allowance has been refreshed, so
+ // the previous cycle's credits are stale.
+ r.data.CycleCredits = 0
+ }
+ r.data.NextResetMs = nextResetMs
+ r.data.CycleStartMs = startMs
+ r.dirty = true
+}
+
+// SetAccount records the subscription metadata reported by /user/status.
+//
+// The panel reads this from memory, so storing it here (rather than fetching
+// on demand) keeps the 15s admin poll free of upstream calls. A nil account is
+// ignored so a transient status outage cannot wipe a previously known plan.
+func (r *Recorder) SetAccount(a *Account) {
+ if a == nil {
+ return
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ // Deep-copy nested quota objects so later caller mutations cannot change
+ // the persisted snapshot behind the recorder's back.
+ r.data.Account = cloneAccount(a)
+ r.dirty = true
+}
+
+func cloneAccount(a *Account) *Account {
+ if a == nil {
+ return nil
+ }
+ cp := *a
+ if a.UserQuota != nil {
+ q := *a.UserQuota
+ cp.UserQuota = &q
+ }
+ if a.AddOnQuota != nil {
+ q := *a.AddOnQuota
+ cp.AddOnQuota = &q
+ }
+ if a.OrgResourcePackage != nil {
+ q := *a.OrgResourcePackage
+ cp.OrgResourcePackage = &q
+ }
+ return &cp
+}
+
+// addMonthsMs shifts an epoch-millis instant by n calendar months.
+func addMonthsMs(ms int64, n int) int64 {
+ return time.UnixMilli(ms).Local().AddDate(0, n, 0).UnixMilli()
+}
+
+// rollCycleLocked zeroes the cycle credits once the known reset boundary has
+// passed, then advances the boundary locally. Caller must hold the mutex.
+//
+// The advance is what makes the rollover happen exactly once: without it,
+// every subsequent request would still see nowMs >= NextResetMs and wipe the
+// fresh total. /user/status later supplies the authoritative boundary, which
+// SetBillingCycle reconciles without zeroing when it names the same cycle.
+//
+// A loop rather than a single step covers a service that stayed down across
+// several cycles.
+func (r *Recorder) rollCycleLocked(nowMs int64) {
+ for r.data.NextResetMs > 0 && nowMs >= r.data.NextResetMs {
+ r.data.CycleStartMs = r.data.NextResetMs
+ r.data.NextResetMs = addMonthsMs(r.data.NextResetMs, 1)
+ r.data.CycleCredits = 0
+ }
}
// Usage is the token accounting payload produced by transform.Usage.
@@ -210,6 +374,11 @@ type Usage struct {
CompletionTokens int
CachedTokens int
Credits float64
+ // NonBillable mirrors the gateway's charge flag, inverted on purpose: a
+ // bool field named Billable would zero to false, so any caller that forgot
+ // to set it would silently drop every credit. This way the zero value
+ // means "billable", which is both the common case and the safe one.
+ NonBillable bool
}
// Flush persists pending data if anything changed since the last flush.
@@ -255,6 +424,19 @@ func (r *Recorder) Report() *Report {
r.mu.Lock()
defer r.mu.Unlock()
+ // Roll the cycle on read as well as on write: the admin panel polls this
+ // every 15s, so an idle service would otherwise keep reporting the
+ // previous cycle's credits long after the boundary passed. The rollover is
+ // idempotent, so this costs nothing once it has already happened.
+ //
+ // Dirty is set only when the boundary actually moved, so a plain read poll
+ // never forces a disk write.
+ prevReset := r.data.NextResetMs
+ r.rollCycleLocked(time.Now().UnixMilli())
+ if r.data.NextResetMs != prevReset {
+ r.dirty = true
+ }
+
rep := &Report{
Total: r.data.Total,
Success: r.data.Success,
@@ -263,9 +445,13 @@ func (r *Recorder) Report() *Report {
CompletionTokens: r.data.CompletionTokens,
CachedTokens: r.data.CachedTokens,
Credits: r.data.Credits,
+ CycleCredits: r.data.CycleCredits,
+ CycleStartMs: r.data.CycleStartMs,
+ NextResetMs: r.data.NextResetMs,
ByModel: []ModelRow{},
Hourly: []HourRow{},
}
+ rep.Account = cloneAccount(r.data.Account)
if r.data.Total > 0 {
rep.SuccessRate = float64(r.data.Success) / float64(r.data.Total)
}
diff --git a/stats/stats_test.go b/stats/stats_test.go
index 0646b04..0c332da 100644
--- a/stats/stats_test.go
+++ b/stats/stats_test.go
@@ -267,3 +267,186 @@ func (f *fakePersister) SaveStats(d *Data) error {
}
return f.saveFn(d)
}
+
+// NonBillable frames must contribute tokens (the work really happened) but not
+// credits (nothing was charged to the subscription allowance).
+func TestRecordUsageNonBillableSkipsCreditsOnly(t *testing.T) {
+ r := NewRecorder(nil)
+ r.RecordUsage(&Usage{PromptTokens: 10, CompletionTokens: 20, Credits: 0.5, NonBillable: true})
+ r.RecordUsage(&Usage{PromptTokens: 1, CompletionTokens: 2, Credits: 0.25})
+
+ rep := r.Report()
+ if rep.PromptTokens != 11 || rep.CompletionTokens != 22 {
+ t.Errorf("tokens must count non-billable frames too: got prompt=%d completion=%d, want 11/22",
+ rep.PromptTokens, rep.CompletionTokens)
+ }
+ if rep.Credits < 0.25-1e-9 || rep.Credits > 0.25+1e-9 {
+ t.Errorf("expected only the billable credits 0.25, got %v", rep.Credits)
+ }
+ if rep.CycleCredits < 0.25-1e-9 || rep.CycleCredits > 0.25+1e-9 {
+ t.Errorf("expected cycle credits 0.25, got %v", rep.CycleCredits)
+ }
+}
+
+// The zero value of Usage must mean billable: a caller that forgets the flag
+// should over-count credits rather than silently lose all of them.
+func TestRecordUsageDefaultsToBillable(t *testing.T) {
+ r := NewRecorder(nil)
+ r.RecordUsage(&Usage{Credits: 1.5})
+ if got := r.Report().CycleCredits; got < 1.5-1e-9 || got > 1.5+1e-9 {
+ t.Errorf("expected 1.5 credits with the flag unset, got %v", got)
+ }
+}
+
+// SetBillingCycle derives the cycle start by stepping back one calendar month,
+// which must land on the gateway's own boundary rather than a fixed 30 days.
+// For the observed Sep 25 reset that is Aug 25, not Aug 26.
+func TestSetBillingCycleDerivesCalendarStart(t *testing.T) {
+ r := NewRecorder(nil)
+ reset := time.Date(2026, time.September, 25, 0, 0, 0, 0, time.Local)
+ r.SetBillingCycle(reset.UnixMilli())
+
+ rep := r.Report()
+ if rep.NextResetMs != reset.UnixMilli() {
+ t.Fatalf("expected next reset %d, got %d", reset.UnixMilli(), rep.NextResetMs)
+ }
+ wantStart := time.Date(2026, time.August, 25, 0, 0, 0, 0, time.Local)
+ if rep.CycleStartMs != wantStart.UnixMilli() {
+ t.Errorf("expected cycle start %s, got %s",
+ wantStart.Format(time.RFC3339), time.UnixMilli(rep.CycleStartMs).Format(time.RFC3339))
+ }
+}
+
+// Repeatedly polling the same boundary must not discard credits already
+// accumulated inside the live cycle.
+func TestSetBillingCycleIdempotent(t *testing.T) {
+ r := NewRecorder(nil)
+ reset := time.Now().Add(72 * time.Hour).UnixMilli()
+ r.SetBillingCycle(reset)
+ r.RecordUsage(&Usage{Credits: 2})
+ r.SetBillingCycle(reset) // a redundant refresh from the account loop
+
+ if got := r.Report().CycleCredits; got < 2-1e-9 || got > 2+1e-9 {
+ t.Errorf("expected cycle credits to survive a redundant boundary set, got %v", got)
+ }
+}
+
+// An unavailable status (0) must leave existing accounting intact rather than
+// wiping it on a transient upstream outage.
+func TestSetBillingCycleIgnoresZero(t *testing.T) {
+ r := NewRecorder(nil)
+ reset := time.Now().Add(72 * time.Hour).UnixMilli()
+ r.SetBillingCycle(reset)
+ r.RecordUsage(&Usage{Credits: 3})
+ r.SetBillingCycle(0)
+
+ rep := r.Report()
+ if rep.NextResetMs != reset {
+ t.Errorf("zero must not clear the known boundary, got %d", rep.NextResetMs)
+ }
+ if rep.CycleCredits < 3-1e-9 || rep.CycleCredits > 3+1e-9 {
+ t.Errorf("zero must not clear cycle credits, got %v", rep.CycleCredits)
+ }
+}
+
+// Crossing the reset boundary must zero the cycle exactly once. Regressing
+// here (re-zeroing on every record) is what would make the new cycle total
+// permanently stuck at the latest single request.
+func TestCycleRolloverHappensOnce(t *testing.T) {
+ r := NewRecorder(nil)
+ // A boundary already in the past: the first record triggers the rollover.
+ past := time.Now().Add(-time.Hour).UnixMilli()
+ r.SetBillingCycle(past)
+
+ r.RecordUsage(&Usage{Credits: 1})
+ r.RecordUsage(&Usage{Credits: 2})
+ r.RecordUsage(&Usage{Credits: 4})
+
+ rep := r.Report()
+ if rep.CycleCredits < 7-1e-9 || rep.CycleCredits > 7+1e-9 {
+ t.Errorf("expected the new cycle to accumulate 7, got %v (rollover fired more than once?)",
+ rep.CycleCredits)
+ }
+ if rep.NextResetMs <= past {
+ t.Errorf("expected the boundary to advance past %d, got %d", past, rep.NextResetMs)
+ }
+ // The advanced boundary must be one calendar month out, so a service that
+ // stays up does not roll over again immediately.
+ advanced := time.UnixMilli(rep.NextResetMs).Local()
+ if d := advanced.Sub(time.UnixMilli(past)); d < 27*24*time.Hour || d > 31*24*time.Hour {
+ t.Errorf("expected the advanced boundary to be ~1 month later, got %v", d)
+ }
+ // Lifetime credits are unaffected by the rollover.
+ if rep.Credits < 7-1e-9 || rep.Credits > 7+1e-9 {
+ t.Errorf("lifetime credits must ignore the rollover, got %v", rep.Credits)
+ }
+}
+
+// SetAccount must copy its argument: aliasing the caller's pointer would let a
+// later mutation reach the persisted snapshot.
+func TestSetAccountCopiesAndIsExposed(t *testing.T) {
+ r := NewRecorder(nil)
+ acct := &Account{
+ Plan: "PLAN_TIER_TEAM", Tag: "Teams", IsQuotaExceeded: false,
+ UserQuota: &Quota{Total: 3000, Used: 2939, Remaining: 61, Percentage: 0.98, Unit: "credits"},
+ OrgResourcePackage: &OrgResourcePackage{Cap: 4000, Available: false, Unit: "credits"},
+ }
+ r.SetAccount(acct)
+ acct.Tag = "mutated"
+ acct.UserQuota.Used = 1
+ acct.OrgResourcePackage.Cap = 1
+
+ rep := r.Report()
+ if rep.Account == nil {
+ t.Fatal("expected the account in the report")
+ }
+ if rep.Account.Tag != "Teams" {
+ t.Errorf("expected the snapshot to be insulated from caller mutation, got %q", rep.Account.Tag)
+ }
+ if rep.Account.UserQuota == nil || rep.Account.UserQuota.Used != 2939 {
+ t.Errorf("expected a deep copy of user quota, got %+v", rep.Account.UserQuota)
+ }
+ if rep.Account.OrgResourcePackage == nil || rep.Account.OrgResourcePackage.Cap != 4000 {
+ t.Errorf("expected a deep copy of organization quota, got %+v", rep.Account.OrgResourcePackage)
+ }
+
+ // A nil account must not wipe a known one.
+ r.SetAccount(nil)
+ if r.Report().Account == nil || r.Report().Account.Tag != "Teams" {
+ t.Error("nil account must leave the previous state intact")
+ }
+}
+
+// A read must roll the cycle too, not just a write: the panel polls every 15s,
+// so a service sitting idle across the reset boundary would otherwise keep
+// reporting the previous cycle's credits.
+func TestReportRollsCycleOnRead(t *testing.T) {
+ r := NewRecorder(nil)
+ r.SetBillingCycle(time.Now().Add(-time.Hour).UnixMilli())
+ // Seed a cycle total as if it had been accumulated before the boundary.
+ r.data.CycleCredits = 9
+
+ rep := r.Report()
+ if rep.CycleCredits != 0 {
+ t.Errorf("expected the stale cycle total to be cleared on read, got %v", rep.CycleCredits)
+ }
+ if rep.NextResetMs <= time.Now().UnixMilli() {
+ t.Errorf("expected the boundary to advance into the future, got %d", rep.NextResetMs)
+ }
+ if !r.dirty {
+ t.Error("expected the rollover to mark data dirty so it gets persisted")
+ }
+}
+
+// A read that does not cross a boundary must not mark the data dirty, otherwise
+// every 15s poll would force a disk write.
+func TestReportStaysCleanWithoutRollover(t *testing.T) {
+ r := NewRecorder(nil)
+ r.SetBillingCycle(time.Now().Add(72 * time.Hour).UnixMilli())
+ r.dirty = false
+
+ r.Report()
+ if r.dirty {
+ t.Error("a rollover-free read must not mark the data dirty")
+ }
+}
diff --git a/transform/transform.go b/transform/transform.go
index 782d959..d16a58e 100644
--- a/transform/transform.go
+++ b/transform/transform.go
@@ -37,6 +37,10 @@ type Usage struct {
CompletionTokensDetails *Detail `json:"completion_tokens_details,omitempty"`
Credits float64 `json:"credits"`
OriginalCredits float64 `json:"original_credits"`
+ // Billable reports whether the gateway charged this request. Frames that
+ // omit the field are treated as billable so pre-existing models keep
+ // contributing to the credits total instead of silently zeroing it.
+ Billable bool `json:"billable"`
}
// Detail is a token breakdown (cached prompt tokens / reasoning tokens).
@@ -284,6 +288,11 @@ func convertIncomingMessage(message map[string]interface{}, toolsEnabled, allowS
if role == "" {
role = "user"
}
+ // Qoder's upstream chat protocol accepts system messages but rejects the
+ // OpenAI-only developer role that Pi uses for its agent instruction.
+ if role == "developer" {
+ role = "system"
+ }
text := normalizeMessageText(message)
anyToolCalls := extractAnyToolCalls(message, text, toolsEnabled)
var structuredToolCalls []NormalizedToolCall
@@ -947,6 +956,12 @@ func extractUsage(v interface{}) *Usage {
TotalTokens: toInt(m["total_tokens"]),
Credits: toFloat(m["credits"]),
OriginalCredits: toFloat(m["original_credits"]),
+ // Default true: only an explicit false suppresses billing. An absent
+ // field must not drop credits from frames of models that predate it.
+ Billable: true,
+ }
+ if b, ok := m["billable"].(bool); ok {
+ u.Billable = b
}
if d, ok := m["prompt_tokens_details"].(map[string]interface{}); ok {
u.PromptTokensDetails = &Detail{CachedTokens: toInt(d["cached_tokens"])}
diff --git a/transform/transform_test.go b/transform/transform_test.go
index ee3ceda..a11090f 100644
--- a/transform/transform_test.go
+++ b/transform/transform_test.go
@@ -23,6 +23,19 @@ func TestBuildMessagesUsesOnlyIncomingOpenAIMessages(t *testing.T) {
}
}
+func TestBuildQoderMessagesNormalizesDeveloperRoleToSystem(t *testing.T) {
+ converted := BuildQoderMessages([]map[string]interface{}{
+ {"role": "developer", "content": "Agent instruction"},
+ {"role": "user", "content": "Hi"},
+ }, "Hi", false)
+ if len(converted) != 2 {
+ t.Fatalf("converted message count = %d, want 2", len(converted))
+ }
+ if converted[0].Role != "system" {
+ t.Errorf("developer role = %q, want system", converted[0].Role)
+ }
+}
+
func TestApplyOpenAIToolConfigRemovesTemplateToolsWhenAbsent(t *testing.T) {
body := map[string]interface{}{
"tools": []interface{}{map[string]interface{}{"type": "function", "function": map[string]interface{}{"name": "Skill"}}},