Merge remote-tracking branch 'origin/main' into feat/v3.1.1

This commit is contained in:
musistudio 2026-09-16 19:50:08 +08:00
commit e6207860c1
6 changed files with 111 additions and 5 deletions

View file

@ -2,6 +2,7 @@ import {
buildBrowserRenderer,
buildCoreServer,
buildRenderer,
buildRequestLogBodyWorker,
buildStyles,
buildTrayRenderer,
buildWebClientBridge,
@ -25,6 +26,7 @@ await Promise.all([
buildCoreServer({ mode }),
buildBrowserRenderer({ mode }),
buildRenderer({ mode }),
buildRequestLogBodyWorker({ mode }),
buildTrayRenderer({ mode }),
buildWebClientBridge({ mode }),
buildStyles({ minify: mode === "production" })

View file

@ -61,7 +61,7 @@ export function resolveUsageModelAttribution(
return attributionFromSelector(logicalModel, logicalModel, isKnownProviderSelector(config, logicalModel));
}
function isKnownProviderSelector(
export function isKnownProviderSelector(
config: Pick<AppConfig, "Providers" | "virtualModelProfiles">,
value: string
): boolean {

View file

@ -8,7 +8,7 @@ import { REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants"
import { estimateUsageCostUsd, providerModelPricingForUsage } from "@ccr/core/models/pricing-service";
import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native";
import { normalizeUsageInputTokens } from "@ccr/core/usage/normalization";
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
import { isKnownProviderSelector, resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
import type {
AppConfig,
GatewayProviderProtocol,
@ -584,6 +584,18 @@ function resolveUsageResponseModelAttribution(
const attribution = resolveUsageModelAttribution(config, decodedClaudeRouteModel);
return !config || attribution.provider ? attribution : {};
}
if (config && model && isKnownProviderSelector(config, model)) {
// The gateway rewrites the response model back to the selector the client
// requested, so a "provider/model" string here is a client-visible route
// selector, not the physical model. Attribute through the selector so
// per-model stats aggregate on the bare model name the provider is
// configured with; physical echoes (unknown provider or no slash) still
// fall through and are kept verbatim.
const attribution = resolveUsageModelAttribution(config, model);
if (attribution.provider) {
return attribution;
}
}
return resolveUsageModelAttribution(config, model, { physicalModel: true });
}

View file

@ -394,6 +394,98 @@ test("UsageStore attributes Claude App encoded response model IDs to the routed
}
});
test("UsageStore attributes client-visible provider-prefixed response models to the bare model", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-client-visible-selector-test-"));
try {
const store = new UsageStore(path.join(dir, "usage.sqlite"));
const config = {
Providers: [
{
baseUrl: "https://dashscope.example.com/api/v2/apps/anthropic",
models: ["ZHIPU/GLM-5.3"],
name: "dashscope-private",
type: "anthropic_messages"
}
],
virtualModelProfiles: []
};
await store.recordCapture({
bodyText: [
"event: message_start",
'data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"dashscope-private/ZHIPU/GLM-5.3","usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}}',
"",
"data: [DONE]",
""
].join("\n"),
client: "Claude Code",
config,
durationMs: 100,
fallbackModel: "dashscope-private/ZHIPU/GLM-5.3",
method: "POST",
path: "/v1/messages",
providerProtocol: "anthropic_messages",
requestId: "client-visible-selector-model",
responseHeaders: new Headers({ "content-type": "text/event-stream; charset=utf-8" }),
statusCode: 200
});
const stats = await store.getStats("today", { includeProxy: true });
assert.equal(stats.models[0]?.model, "ZHIPU/GLM-5.3");
assert.equal(stats.models[0]?.provider, "dashscope-private");
assert.notEqual(stats.models[0]?.model, "dashscope-private/ZHIPU/GLM-5.3");
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("UsageStore keeps physical response models with unknown provider prefixes verbatim", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-physical-slash-model-test-"));
try {
const store = new UsageStore(path.join(dir, "usage.sqlite"));
const config = {
Providers: [
{
baseUrl: "https://dashscope.example.com/api/v2/apps/anthropic",
models: ["ZHIPU/GLM-5.3"],
name: "dashscope-private",
type: "anthropic_messages"
}
],
virtualModelProfiles: []
};
// The upstream echoes the physical model id, whose own name contains a
// slash; "ZHIPU" is not a configured provider, so the echo must be kept
// verbatim instead of being re-attributed as a route selector.
await store.recordCapture({
bodyText: [
"event: message_start",
'data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"ZHIPU/GLM-5.3","usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}}',
"",
"data: [DONE]",
""
].join("\n"),
client: "Claude Code",
config,
durationMs: 100,
fallbackModel: "dashscope-private/ZHIPU/GLM-5.3",
method: "POST",
path: "/v1/messages",
providerProtocol: "anthropic_messages",
requestId: "physical-slash-model",
responseHeaders: new Headers({ "content-type": "text/event-stream; charset=utf-8" }),
statusCode: 200
});
const stats = await store.getStats("today", { includeProxy: true });
assert.equal(stats.models[0]?.model, "ZHIPU/GLM-5.3");
assert.equal(stats.models[0]?.provider, "dashscope-private");
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("lightweight usage synchronization records and deduplicates Fusion internal upstream calls", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-fusion-internal-test-"));
try {

View file

@ -15,7 +15,6 @@
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"baseUrl": ".",
"paths": {
"@/*": ["./packages/ui/src/*"],
"@ccr/cli/*": ["./packages/cli/src/*"],

View file

@ -1,14 +1,15 @@
{
"compilerOptions": {
"composite": true,
"outDir": ".tmp/tsconfig-node",
"tsBuildInfoFile": ".tmp/tsconfig-node/tsconfig.node.tsbuildinfo",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["build/**/*.mjs"]
"include": ["playwright.config.ts"]
}