Skip to content

Commit 1aaea01

Browse files
authored
Merge pull request #192 from cnjack/fix/langfuse-usage-and-grok-image-support
fix: record Langfuse usage details and inherit Grok image support
2 parents aab7d65 + 1601ddc commit 1aaea01

11 files changed

Lines changed: 581 additions & 30 deletions

File tree

internal/model/chatmodel.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type TokenUsage struct {
4242
lastCached int64
4343
lastReasoning int64
4444
lastCacheWrite int64
45+
lastModel string
4546
// cacheSeen is set (sticky) once the provider returns a prompt_tokens_details
4647
// object, so CacheObserved can report "caching supported" even on a 0-hit
4748
// turn. Cleared by Reset (a session boundary), never by ResetContext.
@@ -212,6 +213,7 @@ func (t *TokenUsage) Reset() {
212213
atomic.StoreInt64(&t.turnBaseCached, 0)
213214
t.mu.Lock()
214215
t.byModel = nil
216+
t.lastModel = ""
215217
t.mu.Unlock()
216218
}
217219

@@ -269,9 +271,17 @@ func (t *TokenUsage) AddByModel(model string, prompt, completion, total int) {
269271
t.byModel = make(map[string]int64)
270272
}
271273
t.byModel[model] += int64(total)
274+
t.lastModel = model
272275
t.mu.Unlock()
273276
}
274277

278+
// GetLastModel returns the model name of the last recorded API call.
279+
func (t *TokenUsage) GetLastModel() string {
280+
t.mu.RLock()
281+
defer t.mu.RUnlock()
282+
return t.lastModel
283+
}
284+
275285
// GetByModel returns a snapshot of per-model token totals.
276286
func (t *TokenUsage) GetByModel() map[string]int64 {
277287
t.mu.RLock()

internal/model/registry.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,11 +272,165 @@ func (r *ModelRegistry) MergeConfigProviders(providers map[string]*config.Provid
272272
if cm.Context > 0 {
273273
rm.Limit = &ModelLimit{Context: cm.Context}
274274
}
275+
// Managed rows persist only what the live catalog last wrote. A newer
276+
// Grok/Codex ID often lands before the static registry lists it, so
277+
// omitted capabilities inherit from the closest baked-in sibling
278+
// (grok-4.6 ← grok-4.5) instead of rendering as text-only.
279+
if cm.Managed {
280+
applyRelatedManagedModelDefaults(rm, RelatedRegistryModel(prov, cm.ID))
281+
}
275282
prov.Models[cm.ID] = rm
276283
}
277284
}
278285
}
279286

287+
// RelatedRegistryModel returns a baked-in sibling that shares a versioned
288+
// family key with modelID. grok-4.6 matches grok-4.5; grok-imagine-image
289+
// does not. Used when a live managed catalog lists an ID the static
290+
// registry has not been updated to include yet.
291+
//
292+
// Candidates come from immutable generatedProviders, never from the live
293+
// provider.Models map, so an earlier custom row cannot pollute inheritance.
294+
func RelatedRegistryModel(provider *RegistryProvider, modelID string) *RegistryModel {
295+
if provider == nil {
296+
return nil
297+
}
298+
baked := generatedProviders[provider.ID]
299+
if baked == nil {
300+
return nil
301+
}
302+
return relatedBakedInModel(baked.Models, modelID)
303+
}
304+
305+
func relatedBakedInModel(models map[string]*RegistryModel, modelID string) *RegistryModel {
306+
key := managedModelFamilyKey(modelID)
307+
if key == "" || models == nil {
308+
return nil
309+
}
310+
targetParts := managedModelVersionParts(modelID)
311+
var related *RegistryModel
312+
var relatedDist int
313+
for _, candidate := range models {
314+
if candidate == nil || candidate.ID == modelID || managedModelFamilyKey(candidate.ID) != key {
315+
continue
316+
}
317+
dist := managedModelVersionDistance(managedModelVersionParts(candidate.ID), targetParts)
318+
if !preferRelatedModel(related, relatedDist, candidate, dist) {
319+
continue
320+
}
321+
related = candidate
322+
relatedDist = dist
323+
}
324+
return related
325+
}
326+
327+
func preferRelatedModel(current *RegistryModel, currentDist int, candidate *RegistryModel, candidateDist int) bool {
328+
if current == nil {
329+
return true
330+
}
331+
if candidateDist != currentDist {
332+
return candidateDist < currentDist
333+
}
334+
if candidate.DefaultEnabled != current.DefaultEnabled {
335+
return candidate.DefaultEnabled
336+
}
337+
return candidate.ID < current.ID
338+
}
339+
340+
func managedModelFamilyKey(id string) string {
341+
id = strings.ToLower(strings.TrimSpace(id))
342+
for i := 0; i < len(id); i++ {
343+
if id[i] != '-' || i+1 >= len(id) || id[i+1] < '0' || id[i+1] > '9' {
344+
continue
345+
}
346+
end := i + 1
347+
for end < len(id) && id[end] >= '0' && id[end] <= '9' {
348+
end++
349+
}
350+
return id[:end]
351+
}
352+
return ""
353+
}
354+
355+
func managedModelVersionParts(id string) []int {
356+
id = strings.ToLower(strings.TrimSpace(id))
357+
i := 0
358+
for i < len(id) {
359+
if id[i] == '-' && i+1 < len(id) && id[i+1] >= '0' && id[i+1] <= '9' {
360+
i++
361+
break
362+
}
363+
i++
364+
}
365+
var parts []int
366+
for i < len(id) {
367+
if id[i] < '0' || id[i] > '9' {
368+
if id[i] == '.' || id[i] == '-' {
369+
i++
370+
continue
371+
}
372+
break
373+
}
374+
n := 0
375+
for i < len(id) && id[i] >= '0' && id[i] <= '9' {
376+
n = n*10 + int(id[i]-'0')
377+
i++
378+
}
379+
parts = append(parts, n)
380+
}
381+
return parts
382+
}
383+
384+
func managedModelVersionDistance(a, b []int) int {
385+
n := len(a)
386+
if len(b) > n {
387+
n = len(b)
388+
}
389+
dist := 0
390+
weights := [...]int{1_000_000, 1_000, 1}
391+
for i := 0; i < n; i++ {
392+
av, bv := 0, 0
393+
if i < len(a) {
394+
av = a[i]
395+
}
396+
if i < len(b) {
397+
bv = b[i]
398+
}
399+
d := av - bv
400+
if d < 0 {
401+
d = -d
402+
}
403+
w := 1
404+
if i < len(weights) {
405+
w = weights[i]
406+
}
407+
dist += d * w
408+
}
409+
return dist
410+
}
411+
412+
func applyRelatedManagedModelDefaults(dst *RegistryModel, related *RegistryModel) {
413+
if dst == nil || related == nil {
414+
return
415+
}
416+
if related.Attachment {
417+
dst.Attachment = true
418+
if dst.Modalities == nil && related.Modalities != nil {
419+
dst.Modalities = deepCopyModel(related).Modalities
420+
}
421+
}
422+
if !dst.Reasoning && related.Reasoning {
423+
dst.Reasoning = true
424+
if len(dst.ReasoningOptions) == 0 && len(related.ReasoningOptions) > 0 {
425+
dst.ReasoningOptions = append([]ReasoningOption(nil), related.ReasoningOptions...)
426+
}
427+
}
428+
if dst.Limit == nil && related.Limit != nil && related.Limit.Context > 0 {
429+
limitCopy := *related.Limit
430+
dst.Limit = &limitCopy
431+
}
432+
}
433+
280434
// Load returns the provider/model data.
281435
func (r *ModelRegistry) Load() (map[string]*RegistryProvider, error) {
282436
return r.providers, nil

internal/model/registry_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package model
22

33
import (
44
"testing"
5+
6+
"github.com/cnjack/jcode/internal/config"
57
)
68

79
// TestLookupModel tests model lookup from the generated registry.
@@ -83,3 +85,88 @@ func TestHasProvider(t *testing.T) {
8385
t.Error("Expected HasProvider to return false for non-existent provider")
8486
}
8587
}
88+
89+
func TestMergeConfigProvidersInheritsGrokFamilyImageSupport(t *testing.T) {
90+
registry := NewModelRegistryWithConfig(&config.Config{
91+
Providers: map[string]*config.ProviderConfig{
92+
"xai": {
93+
CustomModels: []config.CustomModelConfig{{
94+
ID: "grok-4.6", Name: "Grok 4.6", ToolCall: true, Managed: true,
95+
}},
96+
},
97+
},
98+
})
99+
_, got, ok := registry.LookupModel("xai", "grok-4.6")
100+
if !ok || got == nil {
101+
t.Fatal("managed grok-4.6 was not merged")
102+
}
103+
if !got.Attachment || !got.SupportsImageInput() {
104+
t.Fatalf("grok-4.6 should inherit grok-4.5 image support: %#v", got)
105+
}
106+
if !got.Reasoning || got.Limit == nil || got.Limit.Context <= 0 {
107+
t.Fatalf("grok-4.6 should inherit grok-4.5 reasoning/context: %#v", got)
108+
}
109+
}
110+
111+
func TestRelatedRegistryModelDoesNotMatchImagineIDs(t *testing.T) {
112+
provider := NewModelRegistry().GetProvider("xai")
113+
if RelatedRegistryModel(provider, "grok-imagine-image") != nil {
114+
t.Fatal("image-generation ids must not inherit chat-family metadata")
115+
}
116+
related := RelatedRegistryModel(provider, "grok-4.6")
117+
if related == nil || related.ID != "grok-4.5" {
118+
t.Fatalf("related grok-4.6 = %#v", related)
119+
}
120+
}
121+
122+
func TestRelatedRegistryModelIgnoresLiveCustomSiblings(t *testing.T) {
123+
registry := NewModelRegistry()
124+
provider := registry.GetProvider("xai")
125+
if provider == nil {
126+
t.Fatal("xai missing")
127+
}
128+
provider.Models["grok-4.7"] = &RegistryModel{
129+
ID: "grok-4.7", Name: "Custom Grok 4.7", DefaultEnabled: true,
130+
}
131+
related := RelatedRegistryModel(provider, "grok-4.6")
132+
if related == nil || related.ID != "grok-4.5" {
133+
t.Fatalf("related grok-4.6 should stay on baked-in grok-4.5, got %#v", related)
134+
}
135+
}
136+
137+
func TestRelatedBakedInModelPicksClosestEnabledSibling(t *testing.T) {
138+
models := map[string]*RegistryModel{
139+
"grok-4.1": {ID: "grok-4.1", DefaultEnabled: true},
140+
"grok-4.5": {ID: "grok-4.5", DefaultEnabled: true},
141+
"grok-4.8": {ID: "grok-4.8", DefaultEnabled: false},
142+
}
143+
got := relatedBakedInModel(models, "grok-4.6")
144+
if got == nil || got.ID != "grok-4.5" {
145+
t.Fatalf("closest sibling for grok-4.6 = %#v", got)
146+
}
147+
148+
models["grok-4.5-preview"] = &RegistryModel{ID: "grok-4.5-preview", DefaultEnabled: true}
149+
got = relatedBakedInModel(models, "grok-4.5-new")
150+
if got == nil || got.ID != "grok-4.5" {
151+
t.Fatalf("tied versions should pick lexicographically first enabled id, got %#v", got)
152+
}
153+
}
154+
155+
func TestMergeConfigProvidersCopiesModalitiesWhenLiveAttachmentIsSet(t *testing.T) {
156+
registry := NewModelRegistryWithConfig(&config.Config{
157+
Providers: map[string]*config.ProviderConfig{
158+
"xai": {
159+
CustomModels: []config.CustomModelConfig{{
160+
ID: "grok-4.6", Name: "Grok 4.6", ToolCall: true, Managed: true, Attachment: true,
161+
}},
162+
},
163+
},
164+
})
165+
_, got, ok := registry.LookupModel("xai", "grok-4.6")
166+
if !ok || got == nil {
167+
t.Fatal("managed grok-4.6 was not merged")
168+
}
169+
if !got.Attachment || !got.SupportsImageInput() {
170+
t.Fatalf("live attachment should still copy related modalities: %#v", got)
171+
}
172+
}

internal/model/token_usage_test.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package model
22

3-
import "testing"
3+
import (
4+
"testing"
5+
6+
openai "github.com/sashabaranov/go-openai"
7+
)
48

59
func TestTokenUsage_AddAndGetFull(t *testing.T) {
610
u := &TokenUsage{}
@@ -136,6 +140,9 @@ func TestTokenUsage_Reset(t *testing.T) {
136140
u := &TokenUsage{}
137141
u.Add(AddParams{Prompt: 100, Completion: 20, Total: 120, Cached: 80, Reasoning: 5})
138142
u.AddByModel("m", 100, 20, 120)
143+
if u.GetLastModel() != "m" {
144+
t.Errorf("GetLastModel() = %q, want m", u.GetLastModel())
145+
}
139146
u.Reset()
140147
if got := u.GetFull(); got.PromptTokens != 0 || got.CachedTokens != 0 || got.CallCount != 0 {
141148
t.Errorf("after Reset GetFull() = %+v, want zero", got)
@@ -146,4 +153,34 @@ func TestTokenUsage_Reset(t *testing.T) {
146153
if u.CacheObserved() {
147154
t.Errorf("after Reset CacheObserved() should be false")
148155
}
156+
if u.GetLastModel() != "" {
157+
t.Errorf("after Reset GetLastModel() = %q, want empty", u.GetLastModel())
158+
}
159+
}
160+
161+
func TestExtractUsage_ReadsAPITotalAndDetails(t *testing.T) {
162+
got := extractUsage(openai.Usage{
163+
PromptTokens: 19,
164+
CompletionTokens: 10,
165+
TotalTokens: 29,
166+
PromptTokensDetails: &openai.PromptTokensDetails{
167+
CachedTokens: 4,
168+
},
169+
CompletionTokensDetails: &openai.CompletionTokensDetails{
170+
ReasoningTokens: 3,
171+
},
172+
})
173+
if got.Prompt != 19 || got.Completion != 10 || got.Total != 29 || got.Cached != 4 || got.Reasoning != 3 {
174+
t.Errorf("extractUsage() = %+v, want prompt/completion/total from API and cached/reasoning from details", got)
175+
}
176+
if !got.CacheDetailsPresent {
177+
t.Error("CacheDetailsPresent = false, want true when prompt_tokens_details is present")
178+
}
179+
}
180+
181+
func TestExtractUsage_DerivesTotalWhenAPIOmitsIt(t *testing.T) {
182+
got := extractUsage(openai.Usage{PromptTokens: 19, CompletionTokens: 10})
183+
if got.Total != 29 {
184+
t.Errorf("extractUsage() Total = %d, want 19+10 when API total_tokens is 0", got.Total)
185+
}
149186
}

0 commit comments

Comments
 (0)