create-windy 0.6.9 → 0.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +4 -1
  2. package/dist/cli.js +865 -305
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +3 -3
  5. package/template/README.md +4 -0
  6. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +2 -0
  7. package/template/apps/agent-server/src/southwind_agent_server/report_presenter.py +19 -0
  8. package/template/apps/agent-server/tests/test_multimodal.py +30 -0
  9. package/template/apps/agent-server/tests/test_postgres_store.py +1 -0
  10. package/template/apps/agent-server/tests/test_report_routes.py +86 -0
  11. package/template/apps/agent-server/tests/test_report_store.py +62 -1
  12. package/template/apps/agent-server/tests/test_sqlite_store.py +1 -0
  13. package/template/apps/server/src/agent/remote-runtime.test.ts +1 -0
  14. package/template/apps/server/src/agent/run-audit.ts +1 -0
  15. package/template/apps/server/src/agent/run-contracts.ts +1 -0
  16. package/template/apps/server/src/agent/run-facade.ts +2 -2
  17. package/template/apps/server/src/agent/run-operation-registry.ts +7 -0
  18. package/template/apps/server/src/agent/run-output-schema.ts +15 -0
  19. package/template/apps/server/src/agent/run-release.ts +11 -0
  20. package/template/apps/server/src/agent/supervisor-output-schema.test.ts +95 -0
  21. package/template/apps/server/src/agent/vision-input.ts +37 -2
  22. package/template/apps/server/src/ai-runtime-report/remote-parser.ts +10 -0
  23. package/template/apps/server/src/ai-runtime-report/service.test.ts +1 -0
  24. package/template/apps/server/src/license/pairing-http.ts +138 -0
  25. package/template/apps/server/src/license/pairing-routes.test.ts +282 -0
  26. package/template/apps/server/src/license/pairing-routes.ts +257 -0
  27. package/template/apps/server/src/license/pairing-service.test.ts +320 -0
  28. package/template/apps/server/src/license/pairing-service.ts +321 -0
  29. package/template/apps/server/src/license/restriction.test.ts +11 -0
  30. package/template/apps/server/src/license/restriction.ts +5 -0
  31. package/template/apps/server/src/license/routes.ts +8 -1
  32. package/template/apps/server/src/license/runtime-service.ts +20 -0
  33. package/template/apps/web/package.json +6 -4
  34. package/template/apps/web/src/components/license/LicensePairingCard.vue +177 -0
  35. package/template/apps/web/src/components/license/LicensePairingCard.webtest.ts +113 -0
  36. package/template/apps/web/src/composables/useLicensePairing.ts +168 -0
  37. package/template/apps/web/src/composables/useLicenseRuntime.ts +11 -0
  38. package/template/apps/web/src/pages/license/LicensePage.vue +8 -0
  39. package/template/apps/web/src/pages/system/AiRuntimePage.vue +3 -1
  40. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.vue +43 -31
  41. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.webtest.ts +15 -4
  42. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeModelUsageList.vue +48 -0
  43. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeModelUsageList.webtest.ts +32 -0
  44. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunDetailDialog.vue +2 -1
  45. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunDetailDialog.webtest.ts +70 -0
  46. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunTable.vue +29 -2
  47. package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunTable.webtest.ts +56 -0
  48. package/template/apps/web/src/services/ai-runtime-api.ts +7 -10
  49. package/template/apps/web/src/services/ai-runtime-api.webtest.ts +115 -2
  50. package/template/apps/web/src/services/license-api.ts +60 -0
  51. package/template/apps/web/src/style.webtest.ts +5 -6
  52. package/template/docs/architecture/ai-runtime.md +5 -0
  53. package/template/docs/license/license-pairing-activation.md +67 -0
  54. package/template/docs/operations/incremental-delivery.md +6 -6
  55. package/template/docs/platform/agent-runtime-observability.md +6 -0
  56. package/template/docs/platform/agent-supervisor-output-contract.md +97 -0
  57. package/template/packages/ai-client/package.json +1 -1
  58. package/template/packages/ai-client/src/agent/contracts/final-answer.test.ts +67 -0
  59. package/template/packages/ai-client/src/agent/contracts/final-answer.ts +53 -0
  60. package/template/packages/ai-client/src/agent/contracts/index.ts +1 -0
  61. package/template/packages/modules/index.ts +1 -0
  62. package/template/packages/modules/package.json +1 -1
  63. package/template/packages/modules/src/system-admin-ui-catalog.ts +5 -1
  64. package/template/packages/modules/src/system-agent-supervisor.test.ts +48 -0
  65. package/template/packages/modules/src/system-agent-supervisor.ts +101 -0
  66. package/template/packages/modules/src/system-api-control-plane.ts +37 -0
  67. package/template/packages/modules/src/system-license-pairing.test.ts +44 -0
  68. package/template/packages/modules/src/system-module-catalog.ts +8 -18
  69. package/template/packages/modules/src/system-module-menu-selection.ts +15 -0
  70. package/template/packages/modules/src/system-module-schema-catalog.ts +14 -0
  71. package/template/packages/modules/src/system-modules.ts +2 -0
  72. package/template/packages/server-sdk/index.ts +5 -0
  73. package/template/packages/server-sdk/package.json +3 -1
  74. package/template/packages/server-sdk/src/ai-operation-registration.ts +4 -0
  75. package/template/packages/server-sdk/src/system-agent-supervisor.test.ts +87 -0
  76. package/template/packages/server-sdk/src/system-agent-supervisor.ts +89 -0
  77. package/template/packages/shared/package.json +1 -1
  78. package/template/packages/shared/src/ai-runtime-report.ts +19 -0
@@ -1,10 +1,18 @@
1
1
  <script setup lang="ts">
2
- import { shallowRef } from "vue";
2
+ import { computed, shallowRef } from "vue";
3
3
  import { SearchIcon } from "@lucide/vue";
4
4
  import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from "@/components/ui/select";
5
13
  import type { AiRuntimeActor, AiRuntimeRange } from "@/services/ai-runtime-api";
6
14
 
7
- defineProps<{
15
+ const props = defineProps<{
8
16
  range: AiRuntimeRange;
9
17
  actorHandle?: string;
10
18
  actors: readonly AiRuntimeActor[];
@@ -17,6 +25,12 @@ const emit = defineEmits<{
17
25
  "search:actors": [value: string];
18
26
  }>();
19
27
  const actorSearch = shallowRef("");
28
+ const allActorsValue = "__all_actors__";
29
+ const selectedActor = computed({
30
+ get: () => props.actorHandle ?? allActorsValue,
31
+ set: (value: string) =>
32
+ emit("update:actorHandle", value === allActorsValue ? undefined : value),
33
+ });
20
34
 
21
35
  const ranges: readonly { value: AiRuntimeRange; label: string }[] = [
22
36
  { value: "day", label: "近 24 小时" },
@@ -24,11 +38,6 @@ const ranges: readonly { value: AiRuntimeRange; label: string }[] = [
24
38
  { value: "retained", label: "保留期" },
25
39
  ];
26
40
 
27
- function selectActor(event: Event) {
28
- const value = (event.target as HTMLSelectElement).value;
29
- emit("update:actorHandle", value || undefined);
30
- }
31
-
32
41
  function searchActors() {
33
42
  emit("search:actors", actorSearch.value.trim());
34
43
  }
@@ -38,11 +47,14 @@ function searchActors() {
38
47
  <div
39
48
  class="flex flex-col gap-3 rounded-lg border bg-card p-3 md:flex-row md:items-center md:justify-between"
40
49
  >
41
- <div class="inline-flex w-fit gap-1 rounded-md bg-muted p-1">
50
+ <div
51
+ class="inline-flex min-h-9 w-fit items-center gap-1 rounded-md bg-muted p-0.5"
52
+ >
42
53
  <Button
43
54
  v-for="item in ranges"
44
55
  :key="item.value"
45
56
  size="sm"
57
+ class="h-8 px-3"
46
58
  :variant="range === item.value ? 'secondary' : 'ghost'"
47
59
  :disabled="disabled"
48
60
  @click="emit('update:range', item.value)"
@@ -51,19 +63,19 @@ function searchActors() {
51
63
  </Button>
52
64
  </div>
53
65
 
54
- <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
55
- <label class="flex items-center gap-2 text-sm text-muted-foreground">
66
+ <div class="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-end">
67
+ <label class="grid gap-1 text-sm text-muted-foreground">
56
68
  查找
57
- <span class="flex">
58
- <input
69
+ <span class="flex min-w-0">
70
+ <Input
59
71
  v-model="actorSearch"
60
- class="h-9 w-44 rounded-l-md border border-input bg-background px-3 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30"
72
+ class="h-9 w-full rounded-r-none border-r-0 focus-visible:z-10 sm:w-44"
61
73
  placeholder="姓名或账号"
62
74
  :disabled="disabled"
63
75
  @keyup.enter="searchActors"
64
76
  />
65
77
  <Button
66
- class="rounded-l-none"
78
+ class="h-9 w-9 shrink-0 rounded-l-none"
67
79
  size="icon"
68
80
  variant="outline"
69
81
  :disabled="disabled"
@@ -74,24 +86,24 @@ function searchActors() {
74
86
  </Button>
75
87
  </span>
76
88
  </label>
77
- <label class="flex items-center gap-2 text-sm text-muted-foreground">
89
+ <label class="grid gap-1 text-sm text-muted-foreground">
78
90
  操作者
79
- <select
80
- class="h-9 min-w-48 rounded-md border border-input bg-background px-3 text-sm text-foreground shadow-xs outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30"
81
- :value="actorHandle || ''"
82
- :disabled="disabled"
83
- @change="selectActor"
84
- >
85
- <option value="">全部操作者</option>
86
- <option
87
- v-for="actor in actors"
88
- :key="actor.handle"
89
- :value="actor.handle"
90
- >
91
- {{ actor.displayName
92
- }}{{ actor.username ? `(${actor.username})` : "" }}
93
- </option>
94
- </select>
91
+ <Select v-model="selectedActor" :disabled="disabled">
92
+ <SelectTrigger class="h-9 w-full sm:w-48" aria-label="筛选操作者">
93
+ <SelectValue placeholder="全部操作者" />
94
+ </SelectTrigger>
95
+ <SelectContent>
96
+ <SelectItem :value="allActorsValue">全部操作者</SelectItem>
97
+ <SelectItem
98
+ v-for="actor in actors"
99
+ :key="actor.handle"
100
+ :value="actor.handle"
101
+ >
102
+ {{ actor.displayName
103
+ }}{{ actor.username ? `(${actor.username})` : "" }}
104
+ </SelectItem>
105
+ </SelectContent>
106
+ </Select>
95
107
  </label>
96
108
  </div>
97
109
  </div>
@@ -1,9 +1,12 @@
1
- import { mount } from "@vue/test-utils";
2
- import { describe, expect, test } from "vitest";
1
+ import { flushPromises, mount } from "@vue/test-utils";
2
+ import { afterEach, describe, expect, test } from "vitest";
3
+ import { Select } from "@/components/ui/select";
3
4
  import AiRuntimeFilters from "./AiRuntimeFilters.vue";
4
5
 
6
+ afterEach(() => document.body.replaceChildren());
7
+
5
8
  describe("AI Runtime 操作者筛选", () => {
6
- test("按姓名搜索后选择服务端返回的操作者", async () => {
9
+ test("按姓名搜索后通过设计系统 Select 选择服务端返回的操作者", async () => {
7
10
  const wrapper = mount(AiRuntimeFilters, {
8
11
  props: {
9
12
  range: "day",
@@ -19,9 +22,17 @@ describe("AI Runtime 操作者筛选", () => {
19
22
 
20
23
  await wrapper.get('input[placeholder="姓名或账号"]').setValue("张三");
21
24
  await wrapper.get('button[aria-label="搜索操作者"]').trigger("click");
22
- await wrapper.get("select").setValue("actor_opaque");
25
+ wrapper.findComponent(Select).vm.$emit("update:modelValue", "actor_opaque");
26
+ await flushPromises();
23
27
 
24
28
  expect(wrapper.emitted("search:actors")).toEqual([["张三"]]);
25
29
  expect(wrapper.emitted("update:actorHandle")).toEqual([["actor_opaque"]]);
30
+ expect(wrapper.find("select").exists()).toBe(false);
31
+ expect(
32
+ wrapper.get('[data-slot="select-trigger"]').attributes("aria-label"),
33
+ ).toBe("筛选操作者");
34
+ expect(wrapper.get('[data-slot="select-trigger"]').attributes("role")).toBe(
35
+ "combobox",
36
+ );
26
37
  });
27
38
  });
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import { Badge } from "@/components/ui/badge";
3
+ import type { AiRuntimeModelUsage } from "@/services/ai-runtime-api";
4
+ import { formatTokenCount } from "../../ai-runtime-presenter";
5
+
6
+ defineProps<{ models: readonly AiRuntimeModelUsage[] }>();
7
+ </script>
8
+
9
+ <template>
10
+ <section aria-labelledby="ai-runtime-model-usage-title" class="space-y-2">
11
+ <div>
12
+ <h3 id="ai-runtime-model-usage-title" class="text-sm font-medium">
13
+ 当前范围的模型与 deployment
14
+ </h3>
15
+ <p class="text-xs text-muted-foreground">
16
+ 模型名优先采用 Provider 回报;未回报时显示请求模型。Token 未上报不会按 0
17
+ 处理。
18
+ </p>
19
+ </div>
20
+ <ul
21
+ v-if="models.length"
22
+ class="flex flex-wrap gap-2"
23
+ aria-label="当前筛选范围内的模型 Token 用量"
24
+ >
25
+ <li
26
+ v-for="item in models"
27
+ :key="`${item.deploymentKey}:${item.model}`"
28
+ class="flex min-w-0 items-start gap-1.5 rounded-md border bg-muted/30 px-2 py-1 text-xs"
29
+ >
30
+ <Badge
31
+ variant="secondary"
32
+ class="h-auto whitespace-normal break-all py-0.5 font-medium"
33
+ >
34
+ {{ item.model }}
35
+ </Badge>
36
+ <span class="min-w-0 break-all text-muted-foreground">
37
+ {{ item.deploymentKey }}
38
+ </span>
39
+ <span class="whitespace-nowrap tabular-nums text-foreground">
40
+ {{ formatTokenCount(item.totalTokens) }} Token
41
+ </span>
42
+ </li>
43
+ </ul>
44
+ <p v-else class="text-sm text-muted-foreground">
45
+ 当前范围内没有已上报的模型事实。
46
+ </p>
47
+ </section>
48
+ </template>
@@ -0,0 +1,32 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, expect, test } from "vitest";
3
+ import AiRuntimeModelUsageList from "./AiRuntimeModelUsageList.vue";
4
+
5
+ describe("AI Runtime 模型用量列表", () => {
6
+ test("模型、deployment 和 Token 在图表外始终可读", () => {
7
+ const wrapper = mount(AiRuntimeModelUsageList, {
8
+ props: {
9
+ models: [
10
+ {
11
+ deploymentKey: "ai.primary",
12
+ model: "provider-model-v2",
13
+ attempts: 2,
14
+ inputTokens: 10,
15
+ outputTokens: 5,
16
+ cachedInputTokens: null,
17
+ reasoningTokens: null,
18
+ totalTokens: 15,
19
+ unknownUsageCount: 0,
20
+ },
21
+ ],
22
+ },
23
+ });
24
+
25
+ expect(wrapper.text()).toContain("provider-model-v2");
26
+ expect(wrapper.text()).toContain("ai.primary");
27
+ expect(wrapper.text()).toContain("15 Token");
28
+ expect(
29
+ wrapper.find("[aria-label='当前筛选范围内的模型 Token 用量']").exists(),
30
+ ).toBe(true);
31
+ });
32
+ });
@@ -127,7 +127,8 @@ const open = defineModel<boolean>("open", { required: true });
127
127
  {{ attempt.reportedModel || attempt.requestedModel }}
128
128
  </div>
129
129
  <div class="text-xs text-muted-foreground">
130
- {{ attempt.deploymentKey }}
130
+ {{ attempt.deploymentKey }} ·
131
+ {{ attempt.reportedModel ? "Provider 回报" : "请求模型" }}
131
132
  </div>
132
133
  </TableCell>
133
134
  <TableCell>{{ runStatusLabel(attempt.outcome) }}</TableCell>
@@ -0,0 +1,70 @@
1
+ import { flushPromises, mount } from "@vue/test-utils";
2
+ import { afterEach, describe, expect, test } from "vitest";
3
+ import AiRuntimeRunDetailDialog from "./AiRuntimeRunDetailDialog.vue";
4
+
5
+ afterEach(() => document.body.replaceChildren());
6
+
7
+ describe("AI Runtime Run 详情", () => {
8
+ test("按 Attempt sequence 保留 deployment、请求模型和 Provider 回报模型", async () => {
9
+ const wrapper = mount(AiRuntimeRunDetailDialog, {
10
+ attachTo: document.body,
11
+ props: {
12
+ open: true,
13
+ detail: {
14
+ runId: "run_1",
15
+ operationKey: "business.answer",
16
+ actor: null,
17
+ status: "completed",
18
+ attempts: 2,
19
+ modelDeployments: [],
20
+ totalTokens: 12,
21
+ cacheStatus: "unknown",
22
+ releaseId: "release_1",
23
+ traceId: "trace_1",
24
+ attemptItems: [
25
+ {
26
+ sequence: 1,
27
+ deploymentKey: "ai.primary",
28
+ requestedModel: "requested-a",
29
+ reportedModel: "provider-a",
30
+ outcome: "completed",
31
+ inputTokens: null,
32
+ outputTokens: null,
33
+ cachedInputTokens: null,
34
+ reasoningTokens: null,
35
+ totalTokens: 10,
36
+ cacheStatus: "unknown",
37
+ startedAt: "2026-07-31T00:00:00.000Z",
38
+ },
39
+ {
40
+ sequence: 2,
41
+ deploymentKey: "ai.fallback",
42
+ requestedModel: "requested-b",
43
+ reportedModel: null,
44
+ outcome: "failed",
45
+ inputTokens: null,
46
+ outputTokens: null,
47
+ cachedInputTokens: null,
48
+ reasoningTokens: null,
49
+ totalTokens: null,
50
+ cacheStatus: "unknown",
51
+ startedAt: "2026-07-31T00:00:01.000Z",
52
+ },
53
+ ],
54
+ },
55
+ },
56
+ });
57
+ await flushPromises();
58
+
59
+ const rows = [...document.querySelectorAll("tbody tr")];
60
+ expect(rows.map((row) => row.textContent)).toEqual(
61
+ expect.arrayContaining([
62
+ expect.stringContaining("provider-a"),
63
+ expect.stringContaining("requested-b"),
64
+ ]),
65
+ );
66
+ expect(rows[0]?.textContent).toContain("ai.primary · Provider 回报");
67
+ expect(rows[1]?.textContent).toContain("ai.fallback · 请求模型");
68
+ wrapper.unmount();
69
+ });
70
+ });
@@ -11,6 +11,7 @@ import {
11
11
  TableRow,
12
12
  } from "@/components/ui/table";
13
13
  import type {
14
+ AiRuntimeModelDeployment,
14
15
  AiRuntimePage,
15
16
  AiRuntimeRunItem,
16
17
  } from "@/services/ai-runtime-api";
@@ -40,11 +41,15 @@ function statusVariant(status: string) {
40
41
  ? "secondary"
41
42
  : "outline";
42
43
  }
44
+
45
+ function modelDeploymentLabel(item: AiRuntimeModelDeployment) {
46
+ return `${item.model} · ${item.deploymentKey}`;
47
+ }
43
48
  </script>
44
49
 
45
50
  <template>
46
51
  <div class="space-y-3">
47
- <div class="overflow-hidden rounded-md border">
52
+ <div class="overflow-x-auto rounded-md border">
48
53
  <Table>
49
54
  <TableHeader>
50
55
  <TableRow>
@@ -105,7 +110,29 @@ function statusVariant(status: string) {
105
110
  {{ runStatusLabel(run.status) }}
106
111
  </Badge>
107
112
  </TableCell>
108
- <TableCell>{{ run.model || "未上报" }}</TableCell>
113
+ <TableCell class="min-w-64">
114
+ <ul
115
+ v-if="run.modelDeployments.length"
116
+ class="flex flex-wrap gap-1"
117
+ :aria-label="`Run ${run.runId} 使用的模型与 deployment`"
118
+ >
119
+ <li
120
+ v-for="item in run.modelDeployments"
121
+ :key="`${item.deploymentKey}:${item.model}`"
122
+ >
123
+ <Badge
124
+ variant="outline"
125
+ class="h-auto max-w-60 whitespace-normal break-all py-0.5 text-left leading-4"
126
+ >
127
+ {{ modelDeploymentLabel(item) }}
128
+ <span v-if="item.attemptCount > 1" class="ml-1">
129
+ ×{{ item.attemptCount }}
130
+ </span>
131
+ </Badge>
132
+ </li>
133
+ </ul>
134
+ <span v-else class="text-muted-foreground">未上报</span>
135
+ </TableCell>
109
136
  <TableCell class="text-right tabular-nums">
110
137
  {{ formatTokenCount(run.totalTokens) }}
111
138
  </TableCell>
@@ -0,0 +1,56 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, expect, test } from "vitest";
3
+ import AiRuntimeRunTable from "./AiRuntimeRunTable.vue";
4
+
5
+ describe("AI Runtime Run 列表", () => {
6
+ test("完整展示多个模型与 deployment 组合,而不是折叠成首项", () => {
7
+ const wrapper = mount(AiRuntimeRunTable, {
8
+ props: {
9
+ runs: {
10
+ items: [
11
+ {
12
+ runId: "run_1",
13
+ operationKey: "business.answer",
14
+ actor: null,
15
+ status: "completed",
16
+ attempts: 3,
17
+ modelDeployments: [
18
+ {
19
+ deploymentKey: "ai.primary",
20
+ model: "provider-a",
21
+ attemptCount: 2,
22
+ },
23
+ {
24
+ deploymentKey: "ai.fallback",
25
+ model: "requested-b",
26
+ attemptCount: 1,
27
+ },
28
+ ],
29
+ totalTokens: 12,
30
+ cacheStatus: "unknown",
31
+ },
32
+ {
33
+ runId: "run_2",
34
+ operationKey: "business.answer",
35
+ actor: null,
36
+ status: "failed",
37
+ attempts: 0,
38
+ modelDeployments: [],
39
+ totalTokens: null,
40
+ cacheStatus: "unknown",
41
+ },
42
+ ],
43
+ total: 2,
44
+ page: 1,
45
+ pageSize: 20,
46
+ },
47
+ },
48
+ });
49
+
50
+ expect(wrapper.text()).toContain("provider-a · ai.primary");
51
+ expect(wrapper.text()).toContain("×2");
52
+ expect(wrapper.text()).toContain("requested-b · ai.fallback");
53
+ expect(wrapper.text()).toContain("未上报");
54
+ expect(wrapper.find(".overflow-x-auto").exists()).toBe(true);
55
+ });
56
+ });
@@ -3,6 +3,7 @@ import type {
3
3
  AiRuntimeActorSummary,
4
4
  AiRuntimeAttemptDetail,
5
5
  AiRuntimeReportSummary,
6
+ AiRuntimeRunModelDeployment,
6
7
  AiRuntimeRunDetail as RuntimeRunDetail,
7
8
  AiRuntimeRunListItem,
8
9
  PageResult,
@@ -18,6 +19,7 @@ export interface AiRuntimeReportQuery {
18
19
  export type AiRuntimeSummary = AiRuntimeReportSummary;
19
20
  export type AiRuntimeModelUsage = AiRuntimeReportSummary["byModel"][number];
20
21
  export type AiRuntimeActivity = AiRuntimeReportSummary["activity"][number];
22
+ export type AiRuntimeModelDeployment = AiRuntimeRunModelDeployment;
21
23
 
22
24
  export interface AiRuntimeRunItem {
23
25
  readonly runId: string;
@@ -28,7 +30,7 @@ export interface AiRuntimeRunItem {
28
30
  readonly finishedAt?: string;
29
31
  readonly durationMs?: number;
30
32
  readonly attempts: number;
31
- readonly model?: string;
33
+ readonly modelDeployments: readonly AiRuntimeModelDeployment[];
32
34
  readonly totalTokens: number | null;
33
35
  readonly cacheStatus: "hit" | "miss" | "unknown";
34
36
  readonly traceId?: string;
@@ -39,7 +41,7 @@ export interface AiRuntimeAttempt {
39
41
  readonly sequence: number;
40
42
  readonly deploymentKey: string;
41
43
  readonly requestedModel: string;
42
- readonly reportedModel?: string;
44
+ readonly reportedModel: string | null;
43
45
  readonly outcome: string;
44
46
  readonly inputTokens: number | null;
45
47
  readonly outputTokens: number | null;
@@ -119,7 +121,7 @@ function runItem(value: AiRuntimeRunListItem): AiRuntimeRunItem {
119
121
  ...(value.finishedAt ? { finishedAt: value.finishedAt } : {}),
120
122
  ...(value.durationMs === null ? {} : { durationMs: value.durationMs }),
121
123
  attempts: value.attemptCount,
122
- model: modelLabel(value.models),
124
+ modelDeployments: value.modelDeployments,
123
125
  totalTokens: value.usage.totalTokens,
124
126
  cacheStatus: value.cacheStatus,
125
127
  };
@@ -139,7 +141,8 @@ function attemptItem(value: AiRuntimeAttemptDetail): AiRuntimeAttempt {
139
141
  return {
140
142
  sequence: value.sequence,
141
143
  deploymentKey: value.deploymentKey,
142
- requestedModel: value.model,
144
+ requestedModel: value.requestedModel,
145
+ reportedModel: value.reportedModel,
143
146
  outcome: value.outcome,
144
147
  inputTokens: value.inputTokens,
145
148
  outputTokens: value.outputTokens,
@@ -156,9 +159,3 @@ function attemptItem(value: AiRuntimeAttemptDetail): AiRuntimeAttempt {
156
159
  ...(value.finishedAt ? { finishedAt: value.finishedAt } : {}),
157
160
  };
158
161
  }
159
-
160
- function modelLabel(models: readonly string[]) {
161
- if (models.length === 0) return undefined;
162
- if (models.length === 1) return models[0];
163
- return `${models[0]} 等 ${models.length} 个`;
164
- }
@@ -25,6 +25,18 @@ describe("AI Runtime 报表 API", () => {
25
25
  finishedAt: null,
26
26
  durationMs: null,
27
27
  attemptCount: 2,
28
+ modelDeployments: [
29
+ {
30
+ deploymentKey: "ai.primary",
31
+ model: "provider-a",
32
+ attemptCount: 1,
33
+ },
34
+ {
35
+ deploymentKey: "ai.fallback",
36
+ model: "requested-b",
37
+ attemptCount: 1,
38
+ },
39
+ ],
28
40
  models: ["gpt-a", "gpt-b"],
29
41
  cacheStatus: "unknown",
30
42
  usage: {
@@ -62,7 +74,18 @@ describe("AI Runtime 报表 API", () => {
62
74
  );
63
75
  expect(page.items[0]).toMatchObject({
64
76
  attempts: 2,
65
- model: "gpt-a 等 2 个",
77
+ modelDeployments: [
78
+ {
79
+ deploymentKey: "ai.primary",
80
+ model: "provider-a",
81
+ attemptCount: 1,
82
+ },
83
+ {
84
+ deploymentKey: "ai.fallback",
85
+ model: "requested-b",
86
+ attemptCount: 1,
87
+ },
88
+ ],
66
89
  totalTokens: null,
67
90
  cacheStatus: "unknown",
68
91
  });
@@ -79,6 +102,7 @@ describe("AI Runtime 报表 API", () => {
79
102
  finishedAt: null,
80
103
  durationMs: null,
81
104
  attemptCount: 0,
105
+ modelDeployments: [],
82
106
  models: [],
83
107
  cacheStatus: "unknown",
84
108
  usage: {
@@ -91,7 +115,30 @@ describe("AI Runtime 报表 API", () => {
91
115
  releaseId: "release_1",
92
116
  traceId: "trace_1",
93
117
  safeErrorCode: null,
94
- attempts: [],
118
+ attempts: [
119
+ {
120
+ attemptId: "attempt_1",
121
+ sequence: 1,
122
+ toolRound: 1,
123
+ deploymentKey: "ai.primary",
124
+ requestedModel: "requested-model",
125
+ reportedModel: "provider-model",
126
+ model: "provider-model",
127
+ outcome: "completed",
128
+ cacheStatus: "unknown",
129
+ usageSource: "provider-reported",
130
+ inputTokens: null,
131
+ outputTokens: null,
132
+ cachedInputTokens: null,
133
+ reasoningTokens: null,
134
+ totalTokens: null,
135
+ firstTokenLatencyMs: null,
136
+ durationMs: null,
137
+ safeErrorCode: null,
138
+ startedAt: "2026-07-31T00:00:00.000Z",
139
+ finishedAt: null,
140
+ },
141
+ ],
95
142
  })
96
143
  .mockResolvedValueOnce({ items: [], total: 0, page: 1, pageSize: 50 });
97
144
 
@@ -108,4 +155,70 @@ describe("AI Runtime 报表 API", () => {
108
155
  "/system/ai-runtime/actors?page=1&pageSize=50&q=%E5%BC%A0+%E4%B8%89",
109
156
  );
110
157
  });
158
+
159
+ test("详情保留 Provider 回报与请求模型的原始区别", async () => {
160
+ mocks.apiRequest.mockResolvedValueOnce({
161
+ runId: "run_1",
162
+ operationKey: "customer.answer",
163
+ actor: null,
164
+ status: "completed",
165
+ startedAt: null,
166
+ finishedAt: null,
167
+ durationMs: null,
168
+ attemptCount: 1,
169
+ modelDeployments: [
170
+ {
171
+ deploymentKey: "ai.primary",
172
+ model: "provider-model",
173
+ attemptCount: 1,
174
+ },
175
+ ],
176
+ models: ["provider-model"],
177
+ cacheStatus: "unknown",
178
+ usage: {
179
+ inputTokens: null,
180
+ outputTokens: null,
181
+ cachedInputTokens: null,
182
+ reasoningTokens: null,
183
+ totalTokens: null,
184
+ },
185
+ releaseId: "release_1",
186
+ traceId: "trace_1",
187
+ safeErrorCode: null,
188
+ attempts: [
189
+ {
190
+ attemptId: "attempt_1",
191
+ sequence: 1,
192
+ toolRound: 1,
193
+ deploymentKey: "ai.primary",
194
+ requestedModel: "requested-model",
195
+ reportedModel: "provider-model",
196
+ model: "provider-model",
197
+ outcome: "completed",
198
+ cacheStatus: "unknown",
199
+ usageSource: "provider-reported",
200
+ inputTokens: null,
201
+ outputTokens: null,
202
+ cachedInputTokens: null,
203
+ reasoningTokens: null,
204
+ totalTokens: null,
205
+ firstTokenLatencyMs: null,
206
+ durationMs: null,
207
+ safeErrorCode: null,
208
+ startedAt: "2026-07-31T00:00:00.000Z",
209
+ finishedAt: null,
210
+ },
211
+ ],
212
+ });
213
+
214
+ const detail = await getAiRuntimeRun("run_1");
215
+
216
+ expect(detail.attemptItems).toEqual([
217
+ expect.objectContaining({
218
+ deploymentKey: "ai.primary",
219
+ requestedModel: "requested-model",
220
+ reportedModel: "provider-model",
221
+ }),
222
+ ]);
223
+ });
111
224
  });