Skip to content

Commit 104109b

Browse files
k1ytmyk1yt
authored andcommitted
feat(stats): add endpoint domain extraction for provider identification in dashboard
1 parent 05bea43 commit 104109b

6 files changed

Lines changed: 149 additions & 1 deletion

File tree

packages/types/src/usage-stats.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ export const UsageEventV1 = z.object({
4545
provider: z.string(),
4646
model: z.string(),
4747
mode: z.string(),
48+
/**
49+
* Domain extracted from the provider's custom base URL (e.g. "kimi.ai",
50+
* "localhost:1234"). Only set when the user configured a custom base URL
51+
* that differs from the provider's default. Absent for default endpoints
52+
* and for providers without a base URL field. Backward compatible:
53+
* events recorded before this field was introduced remain valid.
54+
*/
55+
endpoint: z.string().optional(),
4856
usage: z.object({
4957
inputTokens: SourcedNumber.optional(),
5058
outputTokens: SourcedNumber.optional(),

src/core/task/Task.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,100 @@ const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
142142
const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
143143
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors
144144

145+
// ── Usage Stats: endpoint domain extraction ──────────────────────────────────
146+
147+
/**
148+
* Default base URLs per provider. Only providers with a user-configurable
149+
* base URL field are listed. When the user's configured URL matches the
150+
* default, `endpoint` is left undefined to keep events clean.
151+
*/
152+
const PROVIDER_DEFAULT_BASE_URLS: Partial<Record<string, string>> = {
153+
openai: "https://api.openai.com/v1",
154+
"openai-native": "https://api.openai.com",
155+
openrouter: "https://openrouter.ai/api/v1",
156+
deepseek: "https://api.deepseek.com",
157+
litellm: "http://localhost:4000",
158+
ollama: "http://127.0.0.1:11434",
159+
lmstudio: "http://localhost:1234/v1",
160+
requesty: "https://router.requesty.ai/v1",
161+
}
162+
163+
/**
164+
* Maps a provider name to the corresponding base URL field on ProviderSettings.
165+
* Returns the raw configured value (may be undefined if the user hasn't
166+
* customized it). Providers not in this map have no user-configurable base URL.
167+
*/
168+
function getProviderBaseUrlField(provider: string, config: ProviderSettings): string | undefined {
169+
switch (provider) {
170+
case "anthropic":
171+
return config.anthropicBaseUrl
172+
case "openai":
173+
return config.openAiBaseUrl
174+
case "openai-native":
175+
return config.openAiNativeBaseUrl
176+
case "openrouter":
177+
return config.openRouterBaseUrl
178+
case "deepseek":
179+
return config.deepSeekBaseUrl
180+
case "litellm":
181+
return config.litellmBaseUrl
182+
case "ollama":
183+
return config.ollamaBaseUrl
184+
case "lmstudio":
185+
return config.lmStudioBaseUrl
186+
case "requesty":
187+
return config.requestyBaseUrl
188+
case "zoo-gateway":
189+
return config.zooGatewayBaseUrl
190+
default:
191+
return undefined
192+
}
193+
}
194+
195+
/**
196+
* Extracts a display-friendly endpoint domain from the provider's base URL.
197+
*
198+
* Only returns a value when the user has configured a CUSTOM base URL that
199+
* differs from the provider's default. For localhost / 127.0.0.1 hosts the
200+
* port is included (e.g. "localhost:1234") so distinct local servers can be
201+
* distinguished. Returns undefined for default endpoints, providers without
202+
* a base URL field, or malformed URLs.
203+
*/
204+
function resolveEndpoint(config: ProviderSettings): string | undefined {
205+
const provider = config.apiProvider
206+
if (!provider) return undefined
207+
208+
const configuredUrl = getProviderBaseUrlField(provider, config)
209+
if (!configuredUrl) return undefined
210+
211+
// Only record endpoint when the user customized the base URL.
212+
const defaultUrl = PROVIDER_DEFAULT_BASE_URLS[provider]
213+
if (configuredUrl === defaultUrl) return undefined
214+
215+
// zoo-gateway default is dynamic — skip when it matches the derived default.
216+
if (provider === "zoo-gateway") {
217+
// The dynamic default is `${getZooCodeBaseUrl()}/api/gateway/v1`.
218+
// We can't import getZooCodeBaseUrl here without a circular dependency,
219+
// so we compare against the known suffix pattern. If the configured URL
220+
// ends with /api/gateway/v1 and starts with a zoocode host, treat as default.
221+
if (/^https?:\/\/[^/]*zoocode\.dev\/api\/gateway\/v1\/?$/.test(configuredUrl)) {
222+
return undefined
223+
}
224+
}
225+
226+
try {
227+
const url = new URL(configuredUrl)
228+
const hostname = url.hostname
229+
// Include port for localhost / 127.0.0.1 so distinct local servers differ.
230+
if ((hostname === "localhost" || hostname === "127.0.0.1") && url.port) {
231+
return `${hostname}:${url.port}`
232+
}
233+
return hostname
234+
} catch {
235+
return undefined
236+
}
237+
}
238+
145239
export interface TaskOptions extends CreateTaskOptions {
146240
provider: ClineProvider
147241
apiConfiguration: ProviderSettings
@@ -3168,6 +3262,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
31683262
reasoningInOutput: "unknown",
31693263
costSource: "provider",
31703264
tokenSource: "provider",
3265+
endpoint: resolveEndpoint(this.apiConfiguration),
31713266
}
31723267
// Fire-and-forget: store error must not block task
31733268
this.usageRecorder
@@ -3314,6 +3409,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
33143409
reasoningInOutput: "unknown",
33153410
costSource: "provider",
33163411
tokenSource: "provider",
3412+
endpoint: resolveEndpoint(this.apiConfiguration),
33173413
}
33183414
// Fire-and-forget: store error must not block task
33193415
this.usageRecorder

src/services/stats/UsageAggregator.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,10 @@ export class UsageAggregator {
380380
case "month":
381381
return item.monthBucket ? [item.monthBucket] : []
382382
case "provider":
383-
return [event.provider]
383+
// When an endpoint domain is recorded (custom base URL), append it
384+
// to the provider key so distinct servers appear as separate rows.
385+
// e.g. "openai (kimi.ai)" vs plain "openai" for the default endpoint.
386+
return [event.endpoint ? `${event.provider} (${event.endpoint})` : event.provider]
384387
case "model":
385388
return [event.model]
386389
case "mode":

src/services/stats/UsageRecorder.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export interface UsageRecordingContext {
3737
// source
3838
costSource: UsageValueSource
3939
tokenSource: UsageValueSource
40+
/**
41+
* Domain extracted from the provider's custom base URL. Only set when the
42+
* user configured a custom base URL that differs from the provider default.
43+
* Absent for default endpoints. See resolveEndpoint() in Task.ts.
44+
*/
45+
endpoint?: string
4046
}
4147

4248
// ── UsageRecorder ────────────────────────────────────────────────────────────
@@ -96,6 +102,7 @@ export class UsageRecorder {
96102
provider: ctx.provider,
97103
model: ctx.model,
98104
mode: ctx.mode,
105+
endpoint: ctx.endpoint,
99106
usage: {
100107
inputTokens: ctx.inputTokens > 0 ? { value: ctx.inputTokens, source: ctx.tokenSource } : undefined,
101108
outputTokens: ctx.outputTokens > 0 ? { value: ctx.outputTokens, source: ctx.tokenSource } : undefined,

src/services/stats/__tests__/UsageAggregator.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,22 @@ describe("UsageAggregator", () => {
207207
expect(providers).toEqual(["anthropic", "openai"])
208208
})
209209

210+
it("should separate provider buckets by endpoint domain", () => {
211+
const events = [
212+
makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", provider: "openai", endpoint: "kimi.ai" }),
213+
makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", provider: "openai", endpoint: "kimi.ai" }),
214+
makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", provider: "openai" }), // default endpoint
215+
makeEvent({ eventId: "evt-4", idempotencyKey: "idem-4", provider: "openai", endpoint: "localhost:1234" }),
216+
]
217+
const query = makeQuery({ groupBy: ["provider"] })
218+
219+
const result = aggregator.query(events, query)
220+
221+
expect(result.buckets).toHaveLength(3)
222+
const keys = result.buckets.map((b) => b.key.provider).sort()
223+
expect(keys).toEqual(["openai", "openai (kimi.ai)", "openai (localhost:1234)"])
224+
})
225+
210226
it("should group by model", () => {
211227
const events = [
212228
makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", model: "claude-sonnet-4-20250514" }),

src/services/stats/__tests__/UsageEventStore.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,24 @@ describe("UsageEventStore", () => {
147147
expect(parsed.eventId).toBe(event.eventId)
148148
})
149149

150+
it("should persist optional endpoint field when provided", async () => {
151+
const event = makeEvent({ endpoint: "kimi.ai" })
152+
await store.append(event)
153+
154+
const events = await store.readAll()
155+
expect(events).toHaveLength(1)
156+
expect(events[0].endpoint).toBe("kimi.ai")
157+
})
158+
159+
it("should not require endpoint field (backward compatible)", async () => {
160+
const event = makeEvent()
161+
await store.append(event)
162+
163+
const events = await store.readAll()
164+
expect(events).toHaveLength(1)
165+
expect(events[0].endpoint).toBeUndefined()
166+
})
167+
150168
it("should serialize concurrent appends via promise queue", async () => {
151169
const events = Array.from({ length: 10 }, (_, i) =>
152170
makeEvent({ eventId: `evt-${i}`, idempotencyKey: `idem-${i}` }),

0 commit comments

Comments
 (0)