Skip to content

Commit 47cb966

Browse files
authored
Merge pull request Stack-Cairn#270 from inkdust2021/codex/provider-usage-query
feat(providers): add provider usage queries
2 parents d97399d + 01448fa commit 47cb966

46 files changed

Lines changed: 5147 additions & 246 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/agent-gateway/internal/proto/v2/gateway.pb.go

Lines changed: 270 additions & 127 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/agent-gateway/internal/protocol/pbws/guard.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ func vetAgentRequest(sm session.AgentView, env *gatewayv2.GatewayEnvelope) error
3939
*gatewayv2.GatewayEnvelope_HistoryWorkdirs,
4040
*gatewayv2.GatewayEnvelope_HistoryBranch,
4141
*gatewayv2.GatewayEnvelope_ProviderList,
42+
*gatewayv2.GatewayEnvelope_ProviderUsage,
4243
*gatewayv2.GatewayEnvelope_ProviderModels,
4344
*gatewayv2.GatewayEnvelope_SettingsGet,
4445
*gatewayv2.GatewayEnvelope_SettingsUpdate,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package pbws
2+
3+
import (
4+
"testing"
5+
6+
gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2"
7+
"github.com/liveagent/agent-gateway/internal/session"
8+
)
9+
10+
func TestVetAgentRequestAllowsProviderUsage(t *testing.T) {
11+
env := &gatewayv2.GatewayEnvelope{
12+
Payload: &gatewayv2.GatewayEnvelope_ProviderUsage{
13+
ProviderUsage: &gatewayv2.ProviderUsageRequest{
14+
ProviderId: "provider-1",
15+
Refresh: true,
16+
},
17+
},
18+
}
19+
20+
if err := vetAgentRequest(session.AgentView{}, env); err != nil {
21+
t.Fatalf("vetAgentRequest() error = %v", err)
22+
}
23+
}

crates/agent-gateway/proto/v2/gateway.proto

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ message GatewayEnvelope {
5858
WorkspaceWatchRequest workspace_watch = 90;
5959
ManagedProcessRequest managed_process_request = 91;
6060
HistoryBranchRequest history_branch = 92;
61+
ProviderUsageRequest provider_usage = 93;
6162
}
6263

6364
// Legacy tunnel control/frame payloads (pre-rewrite protocol) and the
@@ -126,6 +127,7 @@ message AgentEnvelope {
126127
ManagedProcessResponse managed_process_response = 91;
127128
ManagedProcessSnapshot managed_process_snapshot = 92;
128129
HistoryBranchResponse history_branch_resp = 93;
130+
ProviderUsageResponse provider_usage_resp = 94;
129131
ErrorResponse error = 99;
130132
}
131133

@@ -1134,3 +1136,12 @@ message ProviderModelsRequest {
11341136
message ProviderModelsResponse {
11351137
string models_json = 1;
11361138
}
1139+
1140+
message ProviderUsageRequest {
1141+
string provider_id = 1;
1142+
bool refresh = 2;
1143+
}
1144+
1145+
message ProviderUsageResponse {
1146+
string result_json = 1;
1147+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { createWebModuleLoader } from "../helpers/load-web-module.mjs";
4+
5+
const loader = createWebModuleLoader();
6+
const forms = loader.loadModule("src/pages/settings/providerUtils.ts");
7+
8+
const usageQuery = {
9+
enabled: true,
10+
mode: "newapi",
11+
script: "",
12+
baseUrl: "https://usage.example.test",
13+
accessToken: "",
14+
accessTokenConfigured: true,
15+
userId: "user-1",
16+
accessKeyId: "key-1",
17+
secretAccessKey: "",
18+
secretAccessKeyConfigured: true,
19+
};
20+
21+
test("WebUI usage query draft preserves configured redacted secrets when saved", () => {
22+
const draft = forms.createUsageQueryDraft(usageQuery, true);
23+
24+
assert.notEqual(draft.accessToken, "");
25+
assert.notEqual(draft.secretAccessKey, "");
26+
assert.deepEqual(forms.serializeUsageQueryDraft(draft, true), usageQuery);
27+
});
28+
29+
test("WebUI usage test action accepts only a persisted provider id", () => {
30+
assert.equal(forms.getPersistedUsageQueryProviderId(undefined), null);
31+
assert.equal(forms.getPersistedUsageQueryProviderId({ id: "" }), null);
32+
assert.equal(forms.getPersistedUsageQueryProviderId({ id: "provider-a" }), "provider-a");
33+
});
34+
35+
test("WebUI custom usage query needs confirmation before its first enabled save", () => {
36+
assert.equal(
37+
forms.requiresCustomUsageQueryConfirmation({ ...usageQuery, mode: "custom" }, false),
38+
true,
39+
);
40+
assert.equal(
41+
forms.requiresCustomUsageQueryConfirmation({ ...usageQuery, mode: "custom" }, true),
42+
false,
43+
);
44+
assert.equal(
45+
forms.requiresCustomUsageQueryConfirmation({ ...usageQuery, mode: "custom", enabled: true }, true),
46+
false,
47+
);
48+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { createGatewayV2Codec } from "../helpers/gateway-v2.mjs";
4+
import { createWebModuleLoader } from "../helpers/load-web-module.mjs";
5+
6+
const requestCalls = [];
7+
const loader = createWebModuleLoader({
8+
mocks: {
9+
"@/lib/gatewaySocket": {
10+
getGatewayWebSocketClient() {
11+
return {
12+
providerUsageQuery(providerId, refresh) {
13+
requestCalls.push({ providerId, refresh });
14+
return Promise.resolve({
15+
entries: [{ label: "Credits", value: "11" }],
16+
queriedAt: 123,
17+
error: null,
18+
isStale: false,
19+
});
20+
},
21+
};
22+
},
23+
},
24+
"@/lib/storage": { loadToken: () => "gateway-token" },
25+
},
26+
});
27+
const usage = loader.loadModule("src/lib/providers/usageQuery.ts");
28+
const adapters = loader.loadModule("src/lib/gatewaySocketV2/adapters.ts");
29+
const codec = createGatewayV2Codec(loader);
30+
31+
test("WebUI query client refreshes one provider through the Gateway", async () => {
32+
requestCalls.length = 0;
33+
34+
const result = await usage.queryProviderUsage("provider-a", true);
35+
36+
assert.equal(result.entries[0].value, "11");
37+
assert.deepEqual(requestCalls, [{ providerId: "provider-a", refresh: true }]);
38+
});
39+
40+
test("WebUI protobuf encodes usage request and decodes JSON response", () => {
41+
const request = codec.decodeClientFrame(
42+
adapters.encodeRequestFrame(
43+
"request-1",
44+
"provider.usage.query",
45+
{ provider_id: "provider-a", refresh: true },
46+
"desktop-agent",
47+
),
48+
);
49+
50+
assert.equal(request.case, "agentRequest");
51+
assert.deepEqual(request.json.agent_request.provider_usage, {
52+
provider_id: "provider-a",
53+
refresh: true,
54+
});
55+
56+
const frame = codec.encodeServerFrame({
57+
request_id: "request-1",
58+
agent_id: "desktop-agent",
59+
agent_response: {
60+
provider_usage_resp: {
61+
result_json: JSON.stringify({
62+
entries: [{ label: "Credits", value: "11" }],
63+
queriedAt: 123,
64+
error: null,
65+
isStale: false,
66+
}),
67+
},
68+
},
69+
});
70+
const decoded = adapters.decodeServerFrame(adapters.decodeServerFrameBinary(frame), {
71+
agentOnline: true,
72+
});
73+
74+
assert.deepEqual(decoded, {
75+
kind: "response",
76+
requestId: "request-1",
77+
agentId: "desktop-agent",
78+
payload: {
79+
entries: [{ label: "Credits", value: "11" }],
80+
queriedAt: 123,
81+
error: null,
82+
isStale: false,
83+
},
84+
});
85+
});
86+
87+
test("WebUI protobuf rejects malformed usage response JSON", () => {
88+
const frame = codec.encodeServerFrame({
89+
request_id: "request-2",
90+
agent_id: "desktop-agent",
91+
agent_response: {
92+
provider_usage_resp: { result_json: "{not-json" },
93+
},
94+
});
95+
96+
const decoded = adapters.decodeServerFrame(adapters.decodeServerFrameBinary(frame), {
97+
agentOnline: true,
98+
});
99+
100+
assert.deepEqual(decoded, {
101+
kind: "error",
102+
requestId: "request-2",
103+
agentId: "desktop-agent",
104+
message: "provider usage response is not valid JSON",
105+
});
106+
});

crates/agent-gateway/web/src/i18n/config.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,34 @@ export const translations: Record<Locale, Record<string, string>> = {
13871387
"settings.promptCacheRetention": "缓存保留",
13881388
"settings.promptCacheRetentionShort": "5 分钟(默认)",
13891389
"settings.promptCacheRetentionLong": "1 小时(仅官方 API)",
1390+
"settings.providerUsageQuery": "用量查询",
1391+
"settings.providerUsageEnabled": "启用用量查询",
1392+
"settings.providerUsageMode": "查询方式",
1393+
"settings.providerUsageMode.codingPlan": "Coding Plan",
1394+
"settings.providerUsageMode.general": "通用模板",
1395+
"settings.providerUsageMode.newapi": "NewAPI 模板",
1396+
"settings.providerUsageMode.custom": "自定义脚本",
1397+
"settings.providerUsageTemplate.general": "使用内置通用余额模板和当前供应商 API Key。",
1398+
"settings.providerUsageTemplate.newapi": "使用内置 NewAPI 模板;可选择单独配置查询地址与凭据。",
1399+
"settings.providerUsageScript": "自定义查询脚本",
1400+
"settings.providerUsageScriptPlaceholder": "返回 { request, extractor } 的 JavaScript 表达式",
1401+
"settings.providerUsageBaseUrl": "查询 Base URL(可选)",
1402+
"settings.providerUsageAccessToken": "Access Token",
1403+
"settings.providerUsageUserId": "User ID",
1404+
"settings.providerUsageAccessKeyId": "AccessKey ID",
1405+
"settings.providerUsageSecretAccessKey": "SecretAccessKey",
1406+
"settings.providerUsageAutoRefreshFixedHint": "启用后每 5 分钟自动刷新当前使用的供应商。",
1407+
"settings.providerUsageTest": "测试查询",
1408+
"settings.providerUsageTestSavedHint": "请先保存供应商,才能测试查询。",
1409+
"settings.providerUsageTestRunning": "正在查询…",
1410+
"settings.providerUsageTestFailed": "查询失败",
1411+
"settings.providerUsageTestEmpty": "查询成功,但没有返回可展示的数据。",
1412+
"settings.providerUsageCustomConfirmTitle": "启用自定义用量脚本?",
1413+
"settings.providerUsageCustomConfirmDescription":
1414+
"自定义脚本可以向其定义的目标发起网络请求。请仅使用可信脚本和端点。",
1415+
"settings.providerUsageCustomConfirmDetail":
1416+
"脚本仅会在保存后由本机服务执行,查询凭据不会发送到 WebUI。",
1417+
"settings.providerUsageCustomConfirmAction": "确认启用",
13901418
"settings.nativeWebSearch": "联网搜索",
13911419
"settings.thinking": "Thinking",
13921420
"settings.thinkingEnabled": "启用 Thinking",
@@ -3480,6 +3508,38 @@ export const translations: Record<Locale, Record<string, string>> = {
34803508
"settings.promptCacheRetention": "Cache retention",
34813509
"settings.promptCacheRetentionShort": "5 minutes (default)",
34823510
"settings.promptCacheRetentionLong": "1 hour (official API only)",
3511+
"settings.providerUsageQuery": "Usage query",
3512+
"settings.providerUsageEnabled": "Enable usage query",
3513+
"settings.providerUsageMode": "Query mode",
3514+
"settings.providerUsageMode.codingPlan": "Coding Plan",
3515+
"settings.providerUsageMode.general": "General template",
3516+
"settings.providerUsageMode.newapi": "NewAPI template",
3517+
"settings.providerUsageMode.custom": "Custom script",
3518+
"settings.providerUsageTemplate.general":
3519+
"Uses the built-in general balance template with this provider's API key.",
3520+
"settings.providerUsageTemplate.newapi":
3521+
"Uses the built-in NewAPI template; optionally configure a separate query URL and credentials.",
3522+
"settings.providerUsageScript": "Custom query script",
3523+
"settings.providerUsageScriptPlaceholder":
3524+
"A JavaScript expression returning { request, extractor }",
3525+
"settings.providerUsageBaseUrl": "Query Base URL (optional)",
3526+
"settings.providerUsageAccessToken": "Access Token",
3527+
"settings.providerUsageUserId": "User ID",
3528+
"settings.providerUsageAccessKeyId": "AccessKey ID",
3529+
"settings.providerUsageSecretAccessKey": "SecretAccessKey",
3530+
"settings.providerUsageAutoRefreshFixedHint":
3531+
"When enabled, the active provider refreshes automatically every 5 minutes.",
3532+
"settings.providerUsageTest": "Test query",
3533+
"settings.providerUsageTestSavedHint": "Save the provider before testing its query.",
3534+
"settings.providerUsageTestRunning": "Querying…",
3535+
"settings.providerUsageTestFailed": "Query failed",
3536+
"settings.providerUsageTestEmpty": "The query succeeded but returned no displayable data.",
3537+
"settings.providerUsageCustomConfirmTitle": "Enable custom usage script?",
3538+
"settings.providerUsageCustomConfirmDescription":
3539+
"A custom script can make network requests to the destinations it defines. Use only trusted scripts and endpoints.",
3540+
"settings.providerUsageCustomConfirmDetail":
3541+
"The script runs only after saving through the local service; query credentials are never sent to the WebUI.",
3542+
"settings.providerUsageCustomConfirmAction": "Enable query",
34833543
"settings.nativeWebSearch": "Web Search",
34843544
"settings.thinking": "Thinking",
34853545
"settings.thinkingEnabled": "Enable Thinking",

crates/agent-gateway/web/src/lib/gatewaySocket.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2703,6 +2703,13 @@ export class GatewayWebSocketClient {
27032703
});
27042704
}
27052705

2706+
async providerUsageQuery<T = unknown>(providerId: string, refresh: boolean): Promise<T> {
2707+
return this.requestWithRecovery<T>("provider.usage.query", {
2708+
provider_id: providerId,
2709+
refresh,
2710+
});
2711+
}
2712+
27062713
dispose() {
27072714
this.disposed = true;
27082715
this.terminalStream.dispose();
@@ -3810,6 +3817,7 @@ export type GatewayWebSocketClientLike = {
38103817
apiKey: string,
38113818
useSystemProxy?: boolean,
38123819
): Promise<unknown>;
3820+
providerUsageQuery<T = unknown>(providerId: string, refresh: boolean): Promise<T>;
38133821
dispose(): void;
38143822
};
38153823

crates/agent-gateway/web/src/lib/gatewaySocketV2/adapters.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
MemoryManageRequestSchema,
6767
ProviderListRequestSchema,
6868
ProviderModelsRequestSchema,
69+
ProviderUsageRequestSchema,
6970
SettingsGetRequestSchema,
7071
SettingsResetSshKnownHostRequestSchema,
7172
SettingsUpdateRequestSchema,
@@ -573,6 +574,14 @@ function agentRequestPayload(type: string, body: J): GatewayEnvelope["payload"]
573574
useSystemProxy: bool(body.use_system_proxy),
574575
}),
575576
};
577+
case "provider.usage.query":
578+
return {
579+
case: "providerUsage",
580+
value: create(ProviderUsageRequestSchema, {
581+
providerId: trimStr(body.provider_id),
582+
refresh: bool(body.refresh),
583+
}),
584+
};
576585
case "settings.get":
577586
return { case: "settingsGet", value: create(SettingsGetRequestSchema, {}) };
578587
case "settings.update":
@@ -1020,6 +1029,13 @@ function decodeAgentResponse(envelope: AgentEnvelope, options: { agentOnline: bo
10201029
frameError("provider model response is not valid JSON");
10211030
}
10221031
break;
1032+
case "providerUsageResp":
1033+
try {
1034+
return parseJson(payload.value.resultJson);
1035+
} catch {
1036+
frameError("provider usage response is not valid JSON");
1037+
}
1038+
break;
10231039
case "settingsGetResp": {
10241040
const raw = payload.value.settingsJson.trim();
10251041
if (!raw) return {};

0 commit comments

Comments
 (0)