forked from EduCraft/curriculum-project-hub
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3b087371a | |||
| 97f7972cc5 | |||
| cc42e6a7c6 | |||
| 4e01c18cac | |||
|
a12984d174
|
|||
| b93acd8e8c | |||
| 35251986af | |||
| 3ee6da7ceb | |||
| 79f72ecca8 |
@@ -123,15 +123,23 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
|
|||||||
workspace or service-user config from widening tools, hooks, MCP servers, or
|
workspace or service-user config from widening tools, hooks, MCP servers, or
|
||||||
sandbox paths.
|
sandbox paths.
|
||||||
- Agent skills are Organization-scoped runtime configuration, not Hub release
|
- Agent skills are Organization-scoped runtime configuration, not Hub release
|
||||||
assets. A controlled host-console installer imports each version into a
|
assets. Skill content is imported into a content-addressed persistent store
|
||||||
content-addressed persistent store and records its digest in PostgreSQL. A
|
through a shared ingestion pipeline (`importSkillDirectory` for host-console
|
||||||
role selects enabled Organization skills alongside its model, system prompt
|
CLI, `importSkillFromFiles` for org-admin web surface) that enforces the same
|
||||||
and tool allowlist. Each run copies only those selected immutable versions
|
safety checks: `SKILL.md` manifest required, 512-file / 16-byte limits,
|
||||||
into a run-scoped plugin outside the project workspace; the sandbox exposes
|
symlink rejection, SHA-256 content addressing. The web surface
|
||||||
that snapshot read-only and deletes it after the run. SDK-bundled skills and
|
(`OrganizationAgentConfiguration.installSkillFromFiles`) writes to the same
|
||||||
filesystem setting sources remain disabled, so project `.claude` content
|
store as the host-console CLI (`installSkill`); both flow through
|
||||||
cannot register skills or widen tools. Requested skill versions are recorded
|
`commitSkillContent` for deduplication and atomic rename into
|
||||||
on `run.created`; SDK initialization remains authoritative loading evidence.
|
`versions/<digest>/`. Org-admin authentication gates the web surface; the
|
||||||
|
content-addressed store remains platform-controlled. A role selects enabled
|
||||||
|
Organization skills alongside its model, system prompt and tool allowlist.
|
||||||
|
Each run copies only those selected immutable versions into a run-scoped
|
||||||
|
plugin outside the project workspace; the sandbox exposes that snapshot
|
||||||
|
read-only and deletes it after the run. SDK-bundled skills and filesystem
|
||||||
|
setting sources remain disabled, so project `.claude` content cannot register
|
||||||
|
skills or widen tools. Requested skill versions are recorded on
|
||||||
|
`run.created`; SDK initialization remains authoritative loading evidence.
|
||||||
- Network: open (see Open Questions).
|
- Network: open (see Open Questions).
|
||||||
|
|
||||||
`bypassPermissions` is kept (headless server — no interactive prompts); the
|
`bypassPermissions` is kept (headless server — no interactive prompts); the
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# ADR 0026: Usage Fact Ledger
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0022 pins the cost-attribution contract for the platform: token,
|
||||||
|
provider-reported cost, run count, and duration are attributed by Organization,
|
||||||
|
Project, Run, model, and Provider Connection; "missing provider cost remains
|
||||||
|
unknown rather than zero." ADR-0021 scopes usage accounting as operational
|
||||||
|
reporting, not payment collection (commercial billing stays deferred).
|
||||||
|
|
||||||
|
The implementation today stores this as **one scalar per `AgentRun`**:
|
||||||
|
`inputTokens`, `outputTokens`, `costUsd?`, `costSource?`, written once from the
|
||||||
|
Claude Agent SDK's `result.total_cost_usd` when the run finishes. This is
|
||||||
|
adequate for a single provider-reported model loop, but it cannot represent:
|
||||||
|
|
||||||
|
- **External capability consumption** inside a run. PDF→Markdown bundle
|
||||||
|
conversion, audio/video transcription, OCR and similar media transforms are
|
||||||
|
not the main agent loop. They run as side effects of a run, may use a
|
||||||
|
different provider/model, may bill in non-token units (pages, seconds,
|
||||||
|
invocations), and may report cost through a different channel than the
|
||||||
|
OpenRouter gateway. Today there is no row to write that cost to — it would
|
||||||
|
either disappear or silently corrupt the run's single scalar.
|
||||||
|
- **Multiple model calls within one run** (e.g. a sub-model invoked by a
|
||||||
|
tool, a gateway-side reroute). The scalar collapses them into one number.
|
||||||
|
- **Pricebook derivation** after the fact. With only a final USD figure and no
|
||||||
|
`(provider, model, occurredAt, tokens)` fact, an operator cannot re-derive
|
||||||
|
cost from a price table when the provider did not report it.
|
||||||
|
|
||||||
|
Treating each external call as a nested `AgentRun` was considered and
|
||||||
|
rejected: `AgentRun` carries lock ownership (ADR-0002), session/provider/role
|
||||||
|
binding (ADR-0017), admission/capacity semantics (ADR-0022), and the
|
||||||
|
user-visible task boundary. External calls hold none of those. Making them
|
||||||
|
`AgentRun`s would pollute run counts, admission, lock semantics, and session
|
||||||
|
continuity, and would still not solve non-token metering.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Introduce **`UsageFact`** as the single source of truth for billable
|
||||||
|
consumption inside an `AgentRun`. An `AgentRun` owns zero or more
|
||||||
|
append-only `UsageFact` rows; each row records one billable consumption event:
|
||||||
|
|
||||||
|
- `kind` — `model_completion` (the main agent loop) | `external_capability`
|
||||||
|
| `tool_proxy`. The kind set is `OPEN`; new kinds must be surfaced, not
|
||||||
|
silently folded into an existing one.
|
||||||
|
- `provider` — e.g. `openrouter`, `mineru`, `openai_whisper`.
|
||||||
|
- `model?`, `inputTokens?`, `outputTokens?` — token metering, optional
|
||||||
|
because non-token capabilities have none.
|
||||||
|
- `quantity?` + `unit?` — non-token metering (pages, audio_seconds,
|
||||||
|
invocations), coexisting with tokens rather than replacing them.
|
||||||
|
- `costUsd?` + `costSource` — `provider_reported` | `pricebook_derived` |
|
||||||
|
`unknown`. `costUsd = null` means **unknown, not zero** (ADR-0022). When the
|
||||||
|
provider reported a cost, `costSource = provider_reported` and that value
|
||||||
|
wins. When only tokens are known, a later pricebook pass may derive
|
||||||
|
`costUsd` with `costSource = pricebook_derived`. When neither is possible,
|
||||||
|
`costSource = unknown` and `costUsd` stays null.
|
||||||
|
- `occurredAt` — when the consumption happened; the pricebook derivation
|
||||||
|
depends on this, not on `AgentRun.finishedAt`, because an external
|
||||||
|
capability may complete before the run finishes.
|
||||||
|
- `capabilityId?` — for `external_capability` facts, the registered capability
|
||||||
|
id (e.g. `pdf_to_md_bundle`, `audio_video_to_text`).
|
||||||
|
- `correlationId?` — external request id for reconciliation / idempotency;
|
||||||
|
not part of the aggregation key.
|
||||||
|
|
||||||
|
### Invariants
|
||||||
|
|
||||||
|
1. **Append-only.** A `UsageFact` row is never updated or deleted. A cost
|
||||||
|
correction is a new row; the old row stays. `onDelete: Cascade` exists only
|
||||||
|
so a hard run delete (itself not a normal path) cleans up its facts.
|
||||||
|
2. **Belongs to exactly one Run; never holds a lock.** A fact is a side-effect
|
||||||
|
ledger of one run, not a sub-run. External capability calls obey the same
|
||||||
|
boundary: their identity is `capabilityId + correlationId`, not `RunId`.
|
||||||
|
3. **Missing cost ≠ zero.** Aggregation MUST NOT sum `null` `costUsd` as 0.
|
||||||
|
A run whose facts all have `costUsd = null` is "cost unknown" — reported as
|
||||||
|
`runsWithoutCost`, exactly as the pre-migration `costUsd = null` runs are
|
||||||
|
today. A run with at least one `costUsd`-bearing fact contributes its sum.
|
||||||
|
|
||||||
|
### Rollup cache
|
||||||
|
|
||||||
|
`AgentRun.costUsd / inputTokens / outputTokens / costSource` columns are kept
|
||||||
|
as a **derived rollup cache**, not dropped:
|
||||||
|
|
||||||
|
- Existing non-`/usage` readers (slash `/usage`, session detail, integration
|
||||||
|
tests asserting `run.costUsd`) continue to work without code changes for
|
||||||
|
historical runs.
|
||||||
|
- On run finish, the writer writes the `UsageFact` row first and then mirrors
|
||||||
|
it onto `AgentRun` as two separate statements, not one transaction. The
|
||||||
|
fact is the truth, so it is written first; the cache is derived, so it is
|
||||||
|
written second. A crash between the two leaves the cache stale, but the
|
||||||
|
usage service re-reads `UsageFact` directly, so staleness is recoverable
|
||||||
|
(and the reverse order would lose the truth, which is not). Keeping the
|
||||||
|
fact insert and the run update in separate statements also avoids holding
|
||||||
|
the `AgentRun` row lock across the FK ShareLock taken by the insert, which
|
||||||
|
deadlocks against concurrent workspace teardown under the cascade path
|
||||||
|
`Organization → Project → AgentRun → UsageFact`.
|
||||||
|
- The org/project usage service and the slash `/usage` command read from
|
||||||
|
`UsageFact` directly — they are the canonical aggregation path.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
A new `UsageFact` table is added. For every existing `AgentRun` with a
|
||||||
|
non-null `costUsd` or non-null `inputTokens`/`outputTokens`, the migration
|
||||||
|
inserts **one synthetic `model_completion` fact** carrying the run's
|
||||||
|
provider, model, tokens, cost, and `costSource`. Its `correlationId` is the
|
||||||
|
run id, so the row is identifiable as a backfill artefact. This keeps
|
||||||
|
historical reporting correct under the new aggregation path. Pre-migration
|
||||||
|
runs with no recorded cost remain `runsWithoutCost` by design, matching
|
||||||
|
ADR-0022's "missing cost ≠ zero" rule and the existing migration
|
||||||
|
`20260709143000_agent_run_cost_tracking`'s "no backfill" stance for the
|
||||||
|
truly unrecorded.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The billing model now supports external capabilities (PDF→MD, ASR, OCR, …)
|
||||||
|
without per-capability schema changes: a new capability is one new
|
||||||
|
`capabilityId` value and one or more `external_capability` facts.
|
||||||
|
- `AgentRun.costUsd` is no longer the truth; it is a convenience cache.
|
||||||
|
Readers that need the truth (cost breakdown, per-capability attribution)
|
||||||
|
must read `UsageFact`. The cache MUST be kept consistent on the write path.
|
||||||
|
- The capability registry, pricebook, and any commercial settlement remain
|
||||||
|
`OPEN` and out of pilot scope (ADR-0021). This ADR only pins the ledger
|
||||||
|
shape and invariants.
|
||||||
|
- `usage.ts` and `/usage` now perform a join against `UsageFact` rather than a
|
||||||
|
single-table scan of `AgentRun`; the index on `(runId, occurredAt)` and
|
||||||
|
`(provider, model, occurredAt)` keeps the existing org/project/report
|
||||||
|
queries bounded.
|
||||||
|
- Cost corrections (e.g. a provider rebills a run) produce a new fact row; the
|
||||||
|
rollup cache must be recomputed. The initial release writes facts once at
|
||||||
|
run finish and does not support post-hoc correction flows — that remains
|
||||||
|
`OPEN`.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- **Capability registry** as a first-class spec entity (`Spec.System.Capability`)
|
||||||
|
with org-scoped enable/disable, input/output contracts, metering schemas,
|
||||||
|
and org-exclusive credentials (ADR-0024 alignment). This ADR only reserves
|
||||||
|
the `capabilityId` field and the `external_capability` fact kind.
|
||||||
|
- **Pricebook** — a versioned price table keyed by `(provider, model, unit)`
|
||||||
|
with time validity. Required to actually produce `pricebook_derived` costs;
|
||||||
|
until then, facts without provider-reported cost stay `unknown`.
|
||||||
|
- **Post-hoc cost correction flow** — append-only today; correction UI and
|
||||||
|
rollup recompute are `OPEN`.
|
||||||
|
- **Commercial billing, invoicing, settlement** — still deferred per ADR-0021.
|
||||||
@@ -34,6 +34,10 @@ HUB_FEISHU_EVENTS_PER_MINUTE="120"
|
|||||||
# to this path and rejects any overlap before installing the unit.
|
# to this path and rejects any overlap before installing the unit.
|
||||||
HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
|
HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
|
||||||
|
|
||||||
|
# Persistent root for the content-addressed agent skill store. Required at hub
|
||||||
|
# startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills).
|
||||||
|
HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/skills"
|
||||||
|
|
||||||
# This process is pinned to exactly one Organization. Feishu credentials are
|
# This process is pinned to exactly one Organization. Feishu credentials are
|
||||||
# resolved from that Organization's encrypted ACTIVE connection.
|
# resolved from that Organization's encrypted ACTIVE connection.
|
||||||
HUB_SILO_ORGANIZATION_ID=""
|
HUB_SILO_ORGANIZATION_ID=""
|
||||||
|
|||||||
@@ -253,6 +253,17 @@ export interface AgentSkillRow {
|
|||||||
boundRoleIds: readonly string[];
|
boundRoleIds: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillFileEntry {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstalledSkillResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contentDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentModelRow {
|
export interface AgentModelRow {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -392,5 +403,11 @@ export const api = {
|
|||||||
skillNames: string[];
|
skillNames: string[];
|
||||||
}>,
|
}>,
|
||||||
agentSkills: (slug: string) => get(`${orgBase(slug)}/agent-skills`) as Promise<{ skills: AgentSkillRow[] }>,
|
agentSkills: (slug: string) => get(`${orgBase(slug)}/agent-skills`) as Promise<{ skills: AgentSkillRow[] }>,
|
||||||
|
agentSkillFiles: (slug: string, name: string) =>
|
||||||
|
get(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/files`) as Promise<{ files: SkillFileEntry[] }>,
|
||||||
|
installAgentSkill: (slug: string, name: string, body: { version: string; files: readonly SkillFileEntry[] }) =>
|
||||||
|
put(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<InstalledSkillResult>,
|
||||||
|
patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) =>
|
||||||
|
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>,
|
||||||
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
|
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
folders,
|
folders,
|
||||||
projects,
|
projects,
|
||||||
slug,
|
slug,
|
||||||
|
onCreateFolder,
|
||||||
|
onCreateProject,
|
||||||
}: {
|
}: {
|
||||||
folder: ExplorerFolder;
|
folder: ExplorerFolder;
|
||||||
folders: ExplorerFolder[];
|
folders: ExplorerFolder[];
|
||||||
@@ -19,30 +21,58 @@
|
|||||||
binding: { chatId: string } | null;
|
binding: { chatId: string } | null;
|
||||||
}[];
|
}[];
|
||||||
slug: string;
|
slug: string;
|
||||||
|
onCreateFolder?: (parentId: string) => void;
|
||||||
|
onCreateProject?: (folderId: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let open = $state(true);
|
let open = $state(true);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<div class="group flex w-full items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100">
|
||||||
type="button"
|
<button type="button" class="flex min-w-0 flex-1 items-center gap-2.5 text-left" onclick={() => (open = !open)}>
|
||||||
class="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm transition hover:bg-surface-100"
|
<span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span>
|
||||||
onclick={() => (open = !open)}
|
<span class="flex h-7 w-7 items-center justify-center border border-warning-300 bg-warning-50 text-warning-800">
|
||||||
>
|
<Icon name="folder" class="h-4 w-4" />
|
||||||
<span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span>
|
</span>
|
||||||
<span class="flex h-7 w-7 items-center justify-center border border-warning-300 bg-warning-50 text-warning-800">
|
<span class="min-w-0 flex-1 truncate font-medium text-surface-900">{folder.name}</span>
|
||||||
<Icon name="folder" class="h-4 w-4" />
|
<span class="saas-badge-neutral">{folder.projectCount} 项目</span>
|
||||||
</span>
|
{#if folder.childFolderCount > 0}
|
||||||
<span class="min-w-0 flex-1 truncate font-medium text-surface-900">{folder.name}</span>
|
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
|
||||||
<span class="saas-badge-neutral">{folder.projectCount} 项目</span>
|
{/if}
|
||||||
{#if folder.childFolderCount > 0}
|
</button>
|
||||||
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
|
{#if onCreateFolder || onCreateProject}
|
||||||
|
<span
|
||||||
|
class="flex shrink-0 items-center gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
|
||||||
|
>
|
||||||
|
{#if onCreateFolder}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-6 w-6 items-center justify-center border border-surface-300 bg-surface-50 text-surface-600 transition hover:border-primary-400 hover:text-primary-700"
|
||||||
|
title="在此新建子文件夹"
|
||||||
|
aria-label="在 {folder.name} 内新建子文件夹"
|
||||||
|
onclick={() => onCreateFolder(folder.id)}
|
||||||
|
>
|
||||||
|
<Icon name="folder-plus" class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if onCreateProject}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-6 w-6 items-center justify-center border border-surface-300 bg-surface-50 text-surface-600 transition hover:border-primary-400 hover:text-primary-700"
|
||||||
|
title="在此新建项目"
|
||||||
|
aria-label="在 {folder.name} 内新建项目"
|
||||||
|
onclick={() => onCreateProject(folder.id)}
|
||||||
|
>
|
||||||
|
<Icon name="file-plus" class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="ml-4 border-l border-surface-300 pl-2">
|
<div class="ml-4 border-l border-surface-300 pl-2">
|
||||||
<FolderTree {folders} {projects} parentId={folder.id} {slug} />
|
<FolderTree {folders} {projects} parentId={folder.id} {slug} {onCreateFolder} {onCreateProject} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
projects,
|
projects,
|
||||||
parentId,
|
parentId,
|
||||||
slug,
|
slug,
|
||||||
|
onCreateFolder,
|
||||||
|
onCreateProject,
|
||||||
}: {
|
}: {
|
||||||
folders: ExplorerFolder[];
|
folders: ExplorerFolder[];
|
||||||
projects: {
|
projects: {
|
||||||
@@ -20,6 +22,8 @@
|
|||||||
}[];
|
}[];
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
onCreateFolder?: (parentId: string) => void;
|
||||||
|
onCreateProject?: (folderId: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let childFolders = $derived(folders.filter((f) => f.parentId === parentId));
|
let childFolders = $derived(folders.filter((f) => f.parentId === parentId));
|
||||||
@@ -44,6 +48,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#each childFolders as f (f.id)}
|
{#each childFolders as f (f.id)}
|
||||||
<FolderNode folder={f} {folders} {projects} {slug} />
|
<FolderNode folder={f} {folders} {projects} {slug} {onCreateFolder} {onCreateProject} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
| 'arrow-left'
|
| 'arrow-left'
|
||||||
| 'folder'
|
| 'folder'
|
||||||
| 'file'
|
| 'file'
|
||||||
|
| 'folder-plus'
|
||||||
|
| 'file-plus'
|
||||||
| 'check'
|
| 'check'
|
||||||
| 'roles';
|
| 'roles';
|
||||||
class?: string;
|
class?: string;
|
||||||
@@ -122,6 +124,22 @@
|
|||||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
{:else if name === 'folder-plus'}
|
||||||
|
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if name === 'file-plus'}
|
||||||
|
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{:else if name === 'check'}
|
{:else if name === 'check'}
|
||||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { fmtDate } from '$lib/format';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
|
let {
|
||||||
|
slug,
|
||||||
|
skill,
|
||||||
|
oninstalled,
|
||||||
|
ondisabled,
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
skill: AgentSkillRow;
|
||||||
|
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
|
||||||
|
ondisabled: (name: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
type FileNode = { path: string; content: string };
|
||||||
|
|
||||||
|
let files = $state<FileNode[]>([]);
|
||||||
|
let selectedPath = $state<string | null>(null);
|
||||||
|
let version = $state(skill.version);
|
||||||
|
let description = $state(skill.description ?? '');
|
||||||
|
let loading = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let dirty = $state(false);
|
||||||
|
let newFilePath = $state('');
|
||||||
|
let showNewFile = $state(false);
|
||||||
|
|
||||||
|
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
|
||||||
|
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
|
||||||
|
const sortedFiles = $derived([...files].sort((a, b) => a.path.localeCompare(b.path)));
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.agentSkillFiles(slug, skill.name);
|
||||||
|
files = res.files.map((f) => ({ path: f.path, content: f.content }));
|
||||||
|
if (files.length > 0 && selectedPath === null) {
|
||||||
|
selectedPath = files[0]!.path;
|
||||||
|
}
|
||||||
|
dirty = false;
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFile(path: string) {
|
||||||
|
selectedPath = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContent(path: string, content: string) {
|
||||||
|
const file = files.find((f) => f.path === path);
|
||||||
|
if (file) {
|
||||||
|
file.content = content;
|
||||||
|
dirty = true;
|
||||||
|
if (path === 'SKILL.md') {
|
||||||
|
const desc = parseDescription(content);
|
||||||
|
if (desc !== null) description = desc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFile() {
|
||||||
|
const path = newFilePath.trim();
|
||||||
|
if (path === '') {
|
||||||
|
toastError('文件路径不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.some((f) => f.path === path)) {
|
||||||
|
toastError(`文件已存在:${path}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith('/') || path.includes('..')) {
|
||||||
|
toastError('文件路径必须为相对路径');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
files.push({ path, content: '' });
|
||||||
|
selectedPath = path;
|
||||||
|
newFilePath = '';
|
||||||
|
showNewFile = false;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFile(path: string) {
|
||||||
|
if (path === 'SKILL.md') {
|
||||||
|
toastError('SKILL.md 是必需的 manifest,不能删除');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
files = files.filter((f) => f.path !== path);
|
||||||
|
if (selectedPath === path) {
|
||||||
|
selectedPath = files.length > 0 ? files[0]!.path : null;
|
||||||
|
}
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDescription(manifest: string): string | null {
|
||||||
|
const match = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(manifest);
|
||||||
|
return match ? match[1]!.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!hasManifest) {
|
||||||
|
toastError('缺少 SKILL.md manifest 文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedVersion = version.trim();
|
||||||
|
if (trimmedVersion === '') {
|
||||||
|
toastError('版本号不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const fileEntries: SkillFileEntry[] = files.map((f) => ({ path: f.path, content: f.content }));
|
||||||
|
const result = await api.installAgentSkill(slug, skill.name, {
|
||||||
|
version: trimmedVersion,
|
||||||
|
files: fileEntries,
|
||||||
|
});
|
||||||
|
dirty = false;
|
||||||
|
toastSuccess('技能已保存');
|
||||||
|
oninstalled(result);
|
||||||
|
await loadFiles();
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disable() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await api.patchAgentSkill(slug, skill.name, { disabled: true });
|
||||||
|
toastSuccess('技能已禁用');
|
||||||
|
ondisabled(skill.name);
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDescription() {
|
||||||
|
const manifest = files.find((f) => f.path === 'SKILL.md');
|
||||||
|
if (!manifest) return;
|
||||||
|
const updated = updateFrontmatter(manifest.content, 'description', description.trim());
|
||||||
|
manifest.content = updated;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFrontmatter(content: string, key: string, value: string): string {
|
||||||
|
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
|
||||||
|
if (regex.test(content)) {
|
||||||
|
return content.replace(regex, `${key}: ${value}`);
|
||||||
|
}
|
||||||
|
const lines = content.split('\n');
|
||||||
|
if (lines[0] === '---') {
|
||||||
|
lines.splice(1, 0, `${key}: ${value}`);
|
||||||
|
} else {
|
||||||
|
lines.unshift('---', `${key}: ${value}`, '---');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (skill.name) loadFiles();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="saas-card-pad">
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="saas-badge-primary font-mono">{skill.name}</span>
|
||||||
|
<span class="text-sm text-surface-700">v{skill.version}</span>
|
||||||
|
{#if skill.disabledAt}
|
||||||
|
<span class="saas-badge-error">已禁用</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="skill-version-{skill.id}" class="saas-label">版本号</label>
|
||||||
|
<input id="skill-version-{skill.id}" class="saas-input" bind:value={version} placeholder="如 0.1.0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="skill-desc-{skill.id}" class="saas-label">描述(同步到 SKILL.md frontmatter)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input id="skill-desc-{skill.id}" class="saas-input" bind:value={description} onchange={saveDescription} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-2 text-xs text-surface-600">
|
||||||
|
<span>digest: <code class="font-mono">{skill.contentDigest.slice(0, 12)}</code></span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>更新于 {fmtDate(skill.updatedAt)}</span>
|
||||||
|
{#if skill.boundRoleIds.length > 0}
|
||||||
|
<span>·</span>
|
||||||
|
<span>绑定角色: {skill.boundRoleIds.join(', ')}</span>
|
||||||
|
{/if}
|
||||||
|
{#if dirty}
|
||||||
|
<span>·</span>
|
||||||
|
<span class="font-medium text-warning-700">未保存</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="py-8 text-center text-sm text-surface-600">加载文件中…</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-[16rem_1fr]">
|
||||||
|
<div class="border border-surface-300 bg-surface-100">
|
||||||
|
<div class="flex items-center justify-between border-b border-surface-300 px-3 py-2">
|
||||||
|
<span class="text-xs font-medium text-surface-700">文件</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-primary-700 hover:text-primary-900"
|
||||||
|
onclick={() => (showNewFile = !showNewFile)}
|
||||||
|
>
|
||||||
|
{showNewFile ? '取消' : '+ 新增'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if showNewFile}
|
||||||
|
<div class="border-b border-surface-300 px-3 py-2">
|
||||||
|
<input
|
||||||
|
class="saas-input text-xs"
|
||||||
|
placeholder="如 reference.md"
|
||||||
|
bind:value={newFilePath}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') addFile();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="max-h-80 overflow-y-auto">
|
||||||
|
{#each sortedFiles as f (f.path)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition hover:bg-surface-200
|
||||||
|
{selectedPath === f.path ? 'bg-primary-100 text-primary-900' : 'text-surface-800'}"
|
||||||
|
onclick={() => selectFile(f.path)}
|
||||||
|
>
|
||||||
|
<Icon name="file" class="h-3.5 w-3.5 shrink-0 opacity-60" />
|
||||||
|
<span class="truncate font-mono text-xs">{f.path}</span>
|
||||||
|
{#if f.path === 'SKILL.md'}
|
||||||
|
<span class="ml-auto text-[10px] text-primary-700">manifest</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="ml-auto text-xs text-surface-500 hover:text-error-700"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteFile(f.path);
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteFile(f.path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if files.length === 0}
|
||||||
|
<div class="px-3 py-4 text-center text-xs text-surface-600">暂无文件</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if selectedFile}
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs text-surface-600">{selectedFile.path}</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
class="h-80 w-full resize-y border border-surface-300 bg-white p-3 font-mono text-xs text-surface-900 focus:border-primary-500 focus:outline-none"
|
||||||
|
bind:value={selectedFile.content}
|
||||||
|
oninput={() => (dirty = true)}
|
||||||
|
></textarea>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-80 items-center justify-center border border-surface-300 bg-surface-100 text-sm text-surface-600">
|
||||||
|
选择一个文件或新建文件
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
|
||||||
|
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
{#if !skill.disabledAt}
|
||||||
|
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
|
||||||
|
禁用
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -10,6 +10,8 @@ export const TOOL_OPTIONS: ToolOption[] = [
|
|||||||
{ id: 'list_files', label: '列目录', group: '文件' },
|
{ id: 'list_files', label: '列目录', group: '文件' },
|
||||||
{ id: 'search_files', label: '搜索', group: '文件' },
|
{ id: 'search_files', label: '搜索', group: '文件' },
|
||||||
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
|
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
|
||||||
|
{ id: 'web_fetch', label: 'WebFetch', group: '网络' },
|
||||||
|
{ id: 'web_search', label: 'WebSearch', group: '网络' },
|
||||||
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
|
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
|
||||||
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
|
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
|
||||||
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
|
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
{ key: 'projects', label: '项目', icon: 'projects' as const },
|
{ key: 'projects', label: '项目', icon: 'projects' as const },
|
||||||
{ key: 'capacity', label: '容量', icon: 'overview' as const },
|
{ key: 'capacity', label: '容量', icon: 'overview' as const },
|
||||||
{ key: 'provider', label: '供应方', icon: 'provider' as const },
|
{ key: 'provider', label: '供应方', icon: 'provider' as const },
|
||||||
|
{ key: 'skills', label: '技能', icon: 'roles' as const },
|
||||||
{ key: 'roles', label: '角色', icon: 'roles' as const },
|
{ key: 'roles', label: '角色', icon: 'roles' as const },
|
||||||
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
|
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,9 +14,7 @@
|
|||||||
import { toastError, toastSuccess } from '$lib/toast';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
const slug = $derived(page.params.slug ?? '');
|
||||||
const org = $derived(
|
const org = $derived(($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null);
|
||||||
($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null,
|
|
||||||
);
|
|
||||||
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
|
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
|
||||||
|
|
||||||
let data = $state<ExplorerData | null>(null);
|
let data = $state<ExplorerData | null>(null);
|
||||||
@@ -32,6 +30,18 @@
|
|||||||
let projectName = $state('');
|
let projectName = $state('');
|
||||||
let projectFolder = $state('');
|
let projectFolder = $state('');
|
||||||
|
|
||||||
|
function openFolderModal(parentId: string) {
|
||||||
|
folderName = '';
|
||||||
|
folderParent = parentId;
|
||||||
|
showFolderModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProjectModal(folderId: string) {
|
||||||
|
projectName = '';
|
||||||
|
projectFolder = folderId;
|
||||||
|
showProjectModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
@@ -113,8 +123,8 @@
|
|||||||
{:else if isAdmin && data}
|
{:else if isAdmin && data}
|
||||||
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
|
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<button class="saas-btn-secondary" onclick={() => (showFolderModal = true)}>新建文件夹</button>
|
<button class="saas-btn-secondary" onclick={() => openFolderModal('')}>新建文件夹</button>
|
||||||
<button class="saas-btn-primary" onclick={() => (showProjectModal = true)}>新建项目</button>
|
<button class="saas-btn-primary" onclick={() => openProjectModal('')}>新建项目</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -122,7 +132,14 @@
|
|||||||
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
|
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
|
||||||
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
|
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
|
||||||
{:else}
|
{:else}
|
||||||
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} />
|
<FolderTree
|
||||||
|
folders={data.folders}
|
||||||
|
projects={data.projects}
|
||||||
|
parentId={null}
|
||||||
|
{slug}
|
||||||
|
onCreateFolder={openFolderModal}
|
||||||
|
onCreateProject={openProjectModal}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,15 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const [r, m, s] = await Promise.all([api.agentRoles(slug), api.agentModels(slug), api.agentSkills(slug)]);
|
const [r, s] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug)]);
|
||||||
roles = r.roles;
|
roles = r.roles;
|
||||||
models = m.models;
|
|
||||||
skills = s.skills;
|
skills = s.skills;
|
||||||
|
// Model fetch hits the provider API and may fail or be slow; load it
|
||||||
|
// independently so roles remain editable even without a model list.
|
||||||
|
models = [];
|
||||||
|
api.agentModels(slug)
|
||||||
|
.then((m) => { models = m.models; })
|
||||||
|
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import SkillEditor from '$lib/components/SkillEditor.svelte';
|
||||||
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
|
const slug = $derived(page.params.slug ?? '');
|
||||||
|
|
||||||
|
let skills = $state<AgentSkillRow[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
let showNewSkill = $state(false);
|
||||||
|
let newSkillName = $state('');
|
||||||
|
let newSkillVersion = $state('0.1.0');
|
||||||
|
let newSkillDescription = $state('');
|
||||||
|
let creating = $state(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const res = await api.agentSkills(slug);
|
||||||
|
skills = res.skills;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSkill() {
|
||||||
|
const name = newSkillName.trim();
|
||||||
|
if (name === '') {
|
||||||
|
toastError('技能名称不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
||||||
|
toastError('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const version = newSkillVersion.trim();
|
||||||
|
if (version === '') {
|
||||||
|
toastError('版本号不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
creating = true;
|
||||||
|
try {
|
||||||
|
const manifest = buildManifest(name, newSkillDescription.trim());
|
||||||
|
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
|
||||||
|
const result = await api.installAgentSkill(slug, name, { version, files });
|
||||||
|
toastSuccess(`技能 ${result.name} 已创建`);
|
||||||
|
newSkillName = '';
|
||||||
|
newSkillDescription = '';
|
||||||
|
showNewSkill = false;
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManifest(name: string, description: string): string {
|
||||||
|
const desc = description === '' ? name : description;
|
||||||
|
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDisabled(_name: string) {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (slug) load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="技能"
|
||||||
|
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<LoadingState />
|
||||||
|
{:else if error}
|
||||||
|
<ErrorBanner message={error} onretry={load} />
|
||||||
|
{:else}
|
||||||
|
<div class="saas-card-pad mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="saas-section-title">新建技能</h2>
|
||||||
|
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
|
||||||
|
{showNewSkill ? '取消' : '+ 新建'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if showNewSkill}
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
|
||||||
|
<input
|
||||||
|
class="saas-input font-mono text-sm"
|
||||||
|
placeholder="技能名(如 typst-help)"
|
||||||
|
bind:value={newSkillName}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="saas-input text-sm"
|
||||||
|
placeholder="版本号"
|
||||||
|
bind:value={newSkillVersion}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="saas-input text-sm"
|
||||||
|
placeholder="描述"
|
||||||
|
bind:value={newSkillDescription}
|
||||||
|
/>
|
||||||
|
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
|
||||||
|
{creating ? '创建中…' : '创建'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-surface-600">
|
||||||
|
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板。
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if skills.length === 0}
|
||||||
|
<div class="saas-card">
|
||||||
|
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each skills as skill (skill.id)}
|
||||||
|
<SkillEditor {slug} {skill} oninstalled={onInstalled} ondisabled={onDisabled} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -95,7 +95,7 @@ flock /var/lock/cph-hub-release-publish bash -c '
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
cd "$HUB_DIR"
|
cd "$HUB_DIR"
|
||||||
npm ci
|
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
|
||||||
npm ci --prefix admin-web
|
npm ci --prefix admin-web
|
||||||
npm run audit:production
|
npm run audit:production
|
||||||
npm run build
|
npm run build
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ if [ "$release_ready" = false ]; then
|
|||||||
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
|
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
|
||||||
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
|
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
|
||||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
|
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
|
||||||
"cd '$HUB_DIR' && npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Ensure the service is installed (idempotent), then restart.
|
# 3. Ensure the service is installed (idempotent), then restart.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.28",
|
"version": "0.0.31",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.28",
|
"version": "0.0.31",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.28",
|
"version": "0.0.31",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- ADR-0026: append-only UsageFact ledger. AgentRun.costUsd/inputTokens/
|
||||||
|
-- outputTokens become a derived rollup cache; the truth is in UsageFact.
|
||||||
|
|
||||||
|
CREATE TABLE "UsageFact" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"runId" TEXT NOT NULL,
|
||||||
|
"occurredAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"model" TEXT,
|
||||||
|
"inputTokens" INTEGER,
|
||||||
|
"outputTokens" INTEGER,
|
||||||
|
"quantity" DECIMAL(18, 6),
|
||||||
|
"unit" TEXT,
|
||||||
|
"costUsd" DECIMAL(18, 8),
|
||||||
|
"costSource" TEXT NOT NULL,
|
||||||
|
"capabilityId" TEXT,
|
||||||
|
"correlationId" TEXT,
|
||||||
|
"metadata" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "UsageFact_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "UsageFact_runId_occurredAt_idx" ON "UsageFact"("runId", "occurredAt");
|
||||||
|
CREATE INDEX "UsageFact_runId_kind_idx" ON "UsageFact"("runId", "kind");
|
||||||
|
CREATE INDEX "UsageFact_provider_model_occurredAt_idx"
|
||||||
|
ON "UsageFact"("provider", "model", "occurredAt");
|
||||||
|
CREATE INDEX "UsageFact_capabilityId_occurredAt_idx"
|
||||||
|
ON "UsageFact"("capabilityId", "occurredAt");
|
||||||
|
|
||||||
|
ALTER TABLE "UsageFact"
|
||||||
|
ADD CONSTRAINT "UsageFact_runId_fkey"
|
||||||
|
FOREIGN KEY ("runId") REFERENCES "AgentRun"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Backfill: one synthetic model_completion fact per AgentRun that has any
|
||||||
|
-- recorded usage (cost or tokens). correlationId = run id marks these as
|
||||||
|
-- backfill artefacts (real facts use an external correlation id or null).
|
||||||
|
-- Runs with no recorded usage stay fact-less and remain runsWithoutCost,
|
||||||
|
-- matching ADR-0022's "missing cost ≠ zero" rule and the pre-existing
|
||||||
|
-- migration 20260709143000_agent_run_cost_tracking's "no backfill for the
|
||||||
|
-- truly unrecorded" stance.
|
||||||
|
INSERT INTO "UsageFact" (
|
||||||
|
"id", "runId", "occurredAt", "kind", "provider", "model",
|
||||||
|
"inputTokens", "outputTokens", "costUsd", "costSource",
|
||||||
|
"correlationId", "metadata"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'usagefact_backfill_' || "AgentRun"."id",
|
||||||
|
"AgentRun"."id",
|
||||||
|
COALESCE("AgentRun"."finishedAt", "AgentRun"."startedAt", CURRENT_TIMESTAMP),
|
||||||
|
'model_completion',
|
||||||
|
"AgentRun"."provider",
|
||||||
|
"AgentRun"."model",
|
||||||
|
"AgentRun"."inputTokens",
|
||||||
|
"AgentRun"."outputTokens",
|
||||||
|
"AgentRun"."costUsd",
|
||||||
|
COALESCE("AgentRun"."costSource", 'unknown'),
|
||||||
|
"AgentRun"."id",
|
||||||
|
'{}'::jsonb
|
||||||
|
FROM "AgentRun"
|
||||||
|
WHERE "AgentRun"."costUsd" IS NOT NULL
|
||||||
|
OR "AgentRun"."inputTokens" IS NOT NULL
|
||||||
|
OR "AgentRun"."outputTokens" IS NOT NULL;
|
||||||
@@ -596,9 +596,13 @@ model AgentRun {
|
|||||||
summary String?
|
summary String?
|
||||||
inputTokens Int?
|
inputTokens Int?
|
||||||
outputTokens Int?
|
outputTokens Int?
|
||||||
/// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact.
|
/// ADR-0026: derived rollup cache of UsageFact rows for this run. The truth
|
||||||
|
/// is in UsageFact; this column is convenience for existing readers. Null
|
||||||
|
/// means this run has no trusted cost fact (ADR-0022: missing cost ≠ zero).
|
||||||
costUsd Decimal? @db.Decimal(18, 8)
|
costUsd Decimal? @db.Decimal(18, 8)
|
||||||
/// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback.
|
/// ADR-0026: semantic source of the rolled-up costUsd (provider_reported |
|
||||||
|
/// pricebook_derived | unknown). Mirrors the dominant CostSource of the
|
||||||
|
/// run's facts; not a pricing-estimate fallback.
|
||||||
costSource String?
|
costSource String?
|
||||||
metadata Json
|
metadata Json
|
||||||
error String?
|
error String?
|
||||||
@@ -612,6 +616,7 @@ model AgentRun {
|
|||||||
projectLock ProjectAgentLock?
|
projectLock ProjectAgentLock?
|
||||||
messages AgentMessage[] @relation("runMessages")
|
messages AgentMessage[] @relation("runMessages")
|
||||||
fileChanges AgentFileChange[] @relation("runFileChanges")
|
fileChanges AgentFileChange[] @relation("runFileChanges")
|
||||||
|
usageFacts UsageFact[] @relation("runUsageFacts")
|
||||||
|
|
||||||
@@index([projectId, status])
|
@@index([projectId, status])
|
||||||
@@index([projectId, finishedAt])
|
@@index([projectId, finishedAt])
|
||||||
@@ -817,3 +822,50 @@ model AgentFileChange {
|
|||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([path])
|
@@index([path])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Usage fact ledger (ADR-0026) ----------------------------------------
|
||||||
|
|
||||||
|
/// ADR-0026: one billable consumption event inside an AgentRun. Append-only;
|
||||||
|
/// the run's AgentRun.costUsd/inputTokens/outputTokens are a derived rollup
|
||||||
|
/// of these rows, not the truth. kind=external_capability carries capabilityId
|
||||||
|
/// for media transforms (PDF→MD, audio/video→text, …). costUsd=null means
|
||||||
|
/// unknown, not zero (ADR-0022). The kind set is OPEN: new kinds must be
|
||||||
|
/// surfaced, not silently folded in.
|
||||||
|
model UsageFact {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
runId String
|
||||||
|
/// When the consumption happened. Pricebook derivation uses this, not
|
||||||
|
/// AgentRun.finishedAt, because an external capability may finish before
|
||||||
|
/// the run ends.
|
||||||
|
occurredAt DateTime
|
||||||
|
/// model_completion | external_capability | tool_proxy. OPEN set.
|
||||||
|
kind String
|
||||||
|
/// e.g. openrouter, mineru, openai_whisper.
|
||||||
|
provider String
|
||||||
|
model String?
|
||||||
|
inputTokens Int?
|
||||||
|
outputTokens Int?
|
||||||
|
/// Non-token meter (pages, audio_seconds, invocations). Coexists with tokens.
|
||||||
|
quantity Decimal? @db.Decimal(18, 6)
|
||||||
|
unit String?
|
||||||
|
/// USD. Null = unknown, NOT zero (ADR-0022). When null, costSource=unknown.
|
||||||
|
costUsd Decimal? @db.Decimal(18, 8)
|
||||||
|
/// provider_reported | pricebook_derived | unknown. Required even when
|
||||||
|
/// costUsd is null, so a reader can distinguish "reported zero" from
|
||||||
|
/// "no report at all".
|
||||||
|
costSource String
|
||||||
|
/// For kind=external_capability, the registered capability id
|
||||||
|
/// (e.g. pdf_to_md_bundle, audio_video_to_text). Null for model_completion.
|
||||||
|
capabilityId String?
|
||||||
|
/// External request id for reconciliation / idempotency. Not an aggregation key.
|
||||||
|
correlationId String?
|
||||||
|
metadata Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
run AgentRun @relation("runUsageFacts", fields: [runId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([runId, occurredAt])
|
||||||
|
@@index([runId, kind])
|
||||||
|
@@index([provider, model, occurredAt])
|
||||||
|
@@index([capabilityId, occurredAt])
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
|
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
|
||||||
*
|
*
|
||||||
* Surface for browsing and editing Organization-scoped Agent roles and the
|
* Surface for browsing and editing Organization-scoped Agent roles, the skills
|
||||||
* skills bound to them. Roles are org-owned data; the default-model picker is
|
* bound to them, and the model picker. The model list is **derived** from the
|
||||||
* constrained to the env-default model registry (ADR-0017: there is no
|
* org's ACTIVE provider connection via OpenRouter's /v1/models API — there is
|
||||||
* org-scoped model list — `OrganizationAgentRole.defaultModel` selects from the
|
* no separate model table to maintain. When no ACTIVE provider exists, the
|
||||||
* platform-enabled set). Skill *installation* is out of band (CLI / seed) per
|
* route falls back to the env-default model registry so the admin surface
|
||||||
* spec `Skill 管理是 org-scoped; 存储机制 OPEN`; this route only lists installed
|
* remains usable.
|
||||||
* skills and binds them to roles.
|
*
|
||||||
|
* Skill management (create, read, edit, disable) is performed in-band through
|
||||||
|
* the deep module `OrganizationAgentConfiguration`, which writes to the same
|
||||||
|
* content-addressed persistent store used by the host-console CLI. All skill
|
||||||
|
* content flows through `importSkillFromFiles` → `inspectSkillDirectory` →
|
||||||
|
* `commitSkillContent`, so the web path and CLI path share one ingestion
|
||||||
|
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file
|
||||||
|
* / 16-byte limits, symlink rejection).
|
||||||
*/
|
*/
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { OrganizationAgentConfiguration } from "../../agent/configuration.js";
|
import { OrganizationAgentConfiguration } from "../../agent/configuration.js";
|
||||||
|
import { ProviderModelCatalog } from "../../connections/providerModelCatalog.js";
|
||||||
import { createDefaultModelRegistry } from "../../settings/runtime.js";
|
import { createDefaultModelRegistry } from "../../settings/runtime.js";
|
||||||
|
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||||
import { handleRouteError } from "../errors.js";
|
import { handleRouteError } from "../errors.js";
|
||||||
|
|
||||||
export interface AgentConfigRouteConfig {
|
export interface AgentConfigRouteConfig {
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
readonly sessionSecret: string;
|
readonly sessionSecret: string;
|
||||||
|
readonly skillStoreRoot: string;
|
||||||
|
readonly secretEnvelope: LocalSecretEnvelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAgentConfigRoutes(
|
export async function registerAgentConfigRoutes(
|
||||||
@@ -26,8 +37,8 @@ export async function registerAgentConfigRoutes(
|
|||||||
config: AgentConfigRouteConfig,
|
config: AgentConfigRouteConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||||
// skillStoreRoot is null: this surface does not install skills (see header).
|
const agentConfig = new OrganizationAgentConfiguration(config.prisma, config.skillStoreRoot);
|
||||||
const agentConfig = new OrganizationAgentConfiguration(config.prisma, null);
|
const modelCatalog = new ProviderModelCatalog(config.prisma, config.secretEnvelope);
|
||||||
|
|
||||||
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
|
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
@@ -115,13 +126,119 @@ export async function registerAgentConfigRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/org/:orgSlug/agent-skills/:name/files", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
|
||||||
|
const skill = skills.find((s) => s.name === name && s.disabledAt === null);
|
||||||
|
if (skill === undefined) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
error: { code: "not_found", message: `skill not found: ${name}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const files = await agentConfig.readSkillFiles({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
contentDigest: skill.contentDigest,
|
||||||
|
});
|
||||||
|
return { files };
|
||||||
|
} catch (err) {
|
||||||
|
return handleRouteError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const body = request.body as { version?: unknown; files?: unknown };
|
||||||
|
if (typeof body.version !== "string" || body.version.trim() === "") {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "version is required" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!Array.isArray(body.files) || body.files.length === 0) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "files must be a non-empty array" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const skillFiles: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
|
||||||
|
for (const entry of body.files) {
|
||||||
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "each file must be an object" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const e = entry as { path?: unknown; content?: unknown };
|
||||||
|
if (typeof e.path !== "string" || typeof e.content !== "string") {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "each file needs string path and content" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
skillFiles.push({ path: e.path, bytes: Buffer.from(e.content, "utf8") });
|
||||||
|
}
|
||||||
|
const result = await agentConfig.installSkillFromFiles({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
files: skillFiles,
|
||||||
|
version: body.version,
|
||||||
|
});
|
||||||
|
// The manifest name is authoritative — reject if it doesn't match the
|
||||||
|
// URL param, so clients can't silently rename a skill under a different
|
||||||
|
// route key.
|
||||||
|
if (result.name !== name) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: {
|
||||||
|
code: "bad_request",
|
||||||
|
message: `skill manifest name "${result.name}" does not match route "${name}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { id: result.id, name: result.name, contentDigest: result.contentDigest };
|
||||||
|
} catch (err) {
|
||||||
|
return handleRouteError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const body = request.body as { description?: unknown; disabled?: unknown };
|
||||||
|
if (body.disabled === true) {
|
||||||
|
await agentConfig.disableSkill({ organizationId: auth.organization.id, name });
|
||||||
|
return { disabled: true };
|
||||||
|
}
|
||||||
|
if (typeof body.description === "string") {
|
||||||
|
await agentConfig.updateSkillDescription({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
name,
|
||||||
|
description: body.description,
|
||||||
|
});
|
||||||
|
return { updated: true };
|
||||||
|
}
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "provide description or disabled: true" },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return handleRouteError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/org/:orgSlug/agent-models", async (request, reply) => {
|
app.get("/api/org/:orgSlug/agent-models", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { orgSlug } = request.params as { orgSlug: string };
|
const { orgSlug } = request.params as { orgSlug: string };
|
||||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
if (auth === null) return;
|
if (auth === null) return;
|
||||||
const registry = createDefaultModelRegistry(process.env);
|
const providerModels = await modelCatalog.listModels(auth.organization.id);
|
||||||
return { models: registry.listModels() };
|
// Fall back to env-default registry when the org has no ACTIVE provider
|
||||||
|
// (or the provider API is unreachable on first load).
|
||||||
|
const models = providerModels.length > 0
|
||||||
|
? providerModels
|
||||||
|
: createDefaultModelRegistry(process.env).listModels();
|
||||||
|
return { models };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleRouteError(reply, err);
|
return handleRouteError(reply, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
|||||||
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
||||||
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
||||||
import { registerCapacityRoutes } from "./capacityRoutes.js";
|
import { registerCapacityRoutes } from "./capacityRoutes.js";
|
||||||
|
import { readSkillStoreRoot } from "../../agent/skillStore.js";
|
||||||
import { registerAgentConfigRoutes } from "./agentConfigRoutes.js";
|
import { registerAgentConfigRoutes } from "./agentConfigRoutes.js";
|
||||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||||
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
||||||
@@ -114,6 +115,8 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
|||||||
await registerAgentConfigRoutes(app, {
|
await registerAgentConfigRoutes(app, {
|
||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
|
skillStoreRoot: readSkillStoreRoot(),
|
||||||
|
secretEnvelope: config.secretEnvelope,
|
||||||
});
|
});
|
||||||
await registerSessionsAndUsageRoutes(app, {
|
await registerSessionsAndUsageRoutes(app, {
|
||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
|
|||||||
+157
-16
@@ -1,7 +1,7 @@
|
|||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { assertSupportedRoleTools } from "./roleTools.js";
|
import { assertSupportedRoleTools } from "./roleTools.js";
|
||||||
import { importSkillDirectory } from "./skillStore.js";
|
import { importSkillDirectory, importSkillFromFiles, readSkillFiles, type SkillFileInput, type SkillFileOutput } from "./skillStore.js";
|
||||||
|
|
||||||
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||||
|
|
||||||
@@ -88,38 +88,179 @@ export class OrganizationAgentConfiguration {
|
|||||||
readonly sourceDir: string;
|
readonly sourceDir: string;
|
||||||
readonly version: string;
|
readonly version: string;
|
||||||
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
||||||
if (this.skillStoreRoot === null) {
|
const storeRoot = this.requireSkillStoreRoot();
|
||||||
throw new Error("skill store root is not configured for this OrganizationAgentConfiguration");
|
|
||||||
}
|
|
||||||
await this.requireActiveOrganization(input.organizationId);
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
const version = nonEmpty(input.version, "skill version");
|
const version = nonEmpty(input.version, "skill version");
|
||||||
const imported = await importSkillDirectory({
|
const imported = await importSkillDirectory({
|
||||||
sourceDir: input.sourceDir,
|
sourceDir: input.sourceDir,
|
||||||
storeRoot: this.skillStoreRoot,
|
storeRoot,
|
||||||
});
|
});
|
||||||
|
return this.commitInstalledSkill({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
imported,
|
||||||
|
version,
|
||||||
|
action: "agent_skill.installed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install or replace a skill from an in-memory file list (web upload path).
|
||||||
|
* Same content-addressed storage, same session archival semantics as
|
||||||
|
* `installSkill`; only the ingestion source differs.
|
||||||
|
*/
|
||||||
|
async installSkillFromFiles(input: {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly files: readonly SkillFileInput[];
|
||||||
|
readonly version: string;
|
||||||
|
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
||||||
|
const storeRoot = this.requireSkillStoreRoot();
|
||||||
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
|
const version = nonEmpty(input.version, "skill version");
|
||||||
|
const imported = await importSkillFromFiles({
|
||||||
|
files: input.files,
|
||||||
|
storeRoot,
|
||||||
|
});
|
||||||
|
return this.commitInstalledSkill({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
imported,
|
||||||
|
version,
|
||||||
|
action: "agent_skill.installed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read all files from the stored skill version identified by content digest.
|
||||||
|
* Returns UTF-8 content for each file; the web editor uses this to populate
|
||||||
|
* its file tree.
|
||||||
|
*/
|
||||||
|
async readSkillFiles(input: {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly contentDigest: string;
|
||||||
|
}): Promise<readonly SkillFileOutput[]> {
|
||||||
|
const storeRoot = this.requireSkillStoreRoot();
|
||||||
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
|
const skill = await this.prisma.organizationAgentSkill.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
contentDigest: input.contentDigest,
|
||||||
|
},
|
||||||
|
select: { id: true, disabledAt: true },
|
||||||
|
});
|
||||||
|
if (skill === null) {
|
||||||
|
throw new Error(`skill not found in organization for digest: ${input.contentDigest}`);
|
||||||
|
}
|
||||||
|
return readSkillFiles({
|
||||||
|
storeRoot,
|
||||||
|
contentDigest: input.contentDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a skill (soft-delete). Sets `disabledAt` and archives active
|
||||||
|
* sessions of every role bound to this skill, since the execution surface
|
||||||
|
* changes when a bound skill disappears.
|
||||||
|
*/
|
||||||
|
async disableSkill(input: { readonly organizationId: string; readonly name: string }): Promise<void> {
|
||||||
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const skill = await tx.organizationAgentSkill.findUnique({
|
||||||
|
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
|
||||||
|
select: { id: true, disabledAt: true, roleBindings: { select: { role: { select: { roleId: true } } } } },
|
||||||
|
});
|
||||||
|
if (skill === null) throw new Error(`active skill not found in organization: ${input.name}`);
|
||||||
|
if (skill.disabledAt !== null) return;
|
||||||
|
await tx.organizationAgentSkill.update({
|
||||||
|
where: { id: skill.id },
|
||||||
|
data: { disabledAt: new Date() },
|
||||||
|
});
|
||||||
|
await archiveRoleSessions(
|
||||||
|
tx,
|
||||||
|
input.organizationId,
|
||||||
|
skill.roleBindings.map((binding) => binding.role.roleId),
|
||||||
|
);
|
||||||
|
await tx.auditEntry.create({
|
||||||
|
data: {
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
action: "agent_skill.disabled",
|
||||||
|
metadata: { name: input.name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update only the `description` label of a skill. This is a label change,
|
||||||
|
* not an execution-surface change — no session archival (ADR-0017).
|
||||||
|
*/
|
||||||
|
async updateSkillDescription(input: {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
|
const description = input.description.trim();
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const skill = await tx.organizationAgentSkill.findUnique({
|
||||||
|
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
|
||||||
|
select: { id: true, disabledAt: true },
|
||||||
|
});
|
||||||
|
if (skill === null || skill.disabledAt !== null) {
|
||||||
|
throw new Error(`active skill not found in organization: ${input.name}`);
|
||||||
|
}
|
||||||
|
await tx.organizationAgentSkill.update({
|
||||||
|
where: { id: skill.id },
|
||||||
|
data: { description: description === "" ? null : description },
|
||||||
|
});
|
||||||
|
await tx.auditEntry.create({
|
||||||
|
data: {
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
action: "agent_skill.description_updated",
|
||||||
|
metadata: { name: input.name, description: description === "" ? null : description },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireSkillStoreRoot(): string {
|
||||||
|
if (this.skillStoreRoot === null) {
|
||||||
|
throw new Error("skill store root is not configured for this OrganizationAgentConfiguration");
|
||||||
|
}
|
||||||
|
return this.skillStoreRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared DB commit for an imported skill: upsert the skill record, archive
|
||||||
|
* affected sessions if the content digest changed, and write an audit entry.
|
||||||
|
*/
|
||||||
|
private async commitInstalledSkill(input: {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly imported: { readonly name: string; readonly description: string | undefined; readonly contentDigest: string };
|
||||||
|
readonly version: string;
|
||||||
|
readonly action: string;
|
||||||
|
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const previous = await tx.organizationAgentSkill.findUnique({
|
const previous = await tx.organizationAgentSkill.findUnique({
|
||||||
where: { organizationId_name: { organizationId: input.organizationId, name: imported.name } },
|
where: { organizationId_name: { organizationId: input.organizationId, name: input.imported.name } },
|
||||||
select: { contentDigest: true },
|
select: { contentDigest: true },
|
||||||
});
|
});
|
||||||
const skill = await tx.organizationAgentSkill.upsert({
|
const skill = await tx.organizationAgentSkill.upsert({
|
||||||
where: {
|
where: {
|
||||||
organizationId_name: {
|
organizationId_name: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
name: imported.name,
|
name: input.imported.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
name: imported.name,
|
name: input.imported.name,
|
||||||
version,
|
version: input.version,
|
||||||
description: imported.description ?? null,
|
description: input.imported.description ?? null,
|
||||||
contentDigest: imported.contentDigest,
|
contentDigest: input.imported.contentDigest,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
version,
|
version: input.version,
|
||||||
description: imported.description ?? null,
|
description: input.imported.description ?? null,
|
||||||
contentDigest: imported.contentDigest,
|
contentDigest: input.imported.contentDigest,
|
||||||
disabledAt: null,
|
disabledAt: null,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -139,10 +280,10 @@ export class OrganizationAgentConfiguration {
|
|||||||
await tx.auditEntry.create({
|
await tx.auditEntry.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
action: "agent_skill.installed",
|
action: input.action,
|
||||||
metadata: {
|
metadata: {
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
version,
|
version: input.version,
|
||||||
contentDigest: skill.contentDigest,
|
contentDigest: skill.contentDigest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = ["Read", "Write", "Bash", "Glob", "Grep"] as const;
|
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
|
||||||
|
"Read",
|
||||||
|
"Write",
|
||||||
|
"Bash",
|
||||||
|
"Glob",
|
||||||
|
"Grep",
|
||||||
|
"WebFetch",
|
||||||
|
"WebSearch",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
|
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
|
||||||
export const CPH_HUB_MCP_TOOL_IDS = [
|
export const CPH_HUB_MCP_TOOL_IDS = [
|
||||||
@@ -26,11 +34,15 @@ const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
|
|||||||
// would need a separate command-policy layer.
|
// would need a separate command-policy layer.
|
||||||
["cph_check", ["Bash"]],
|
["cph_check", ["Bash"]],
|
||||||
["cph_build", ["Bash"]],
|
["cph_build", ["Bash"]],
|
||||||
|
["web_fetch", ["WebFetch"]],
|
||||||
|
["web_search", ["WebSearch"]],
|
||||||
["Read", ["Read"]],
|
["Read", ["Read"]],
|
||||||
["Write", ["Write"]],
|
["Write", ["Write"]],
|
||||||
["Bash", ["Bash"]],
|
["Bash", ["Bash"]],
|
||||||
["Glob", ["Glob"]],
|
["Glob", ["Glob"]],
|
||||||
["Grep", ["Grep"]],
|
["Grep", ["Grep"]],
|
||||||
|
["WebFetch", ["WebFetch"]],
|
||||||
|
["WebSearch", ["WebSearch"]],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
|
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
|
||||||
|
|||||||
+102
-8
@@ -34,40 +34,134 @@ export async function importSkillDirectory(input: {
|
|||||||
readonly sourceDir: string;
|
readonly sourceDir: string;
|
||||||
readonly storeRoot: string;
|
readonly storeRoot: string;
|
||||||
}): Promise<ImportedSkillContent> {
|
}): Promise<ImportedSkillContent> {
|
||||||
const source = await inspectSkillDirectory(input.sourceDir);
|
return commitSkillContent({
|
||||||
|
storeRoot: input.storeRoot,
|
||||||
|
prepare: async (dir) => {
|
||||||
|
await cp(input.sourceDir, dir, { recursive: true, force: false, errorOnExist: true });
|
||||||
|
return inspectSkillDirectory(dir);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a skill from an in-memory file list (web upload path). Each file
|
||||||
|
* path is relative to the skill root and must be a simple relative path
|
||||||
|
* (no absolute, no `..`). A `SKILL.md` manifest is required.
|
||||||
|
*/
|
||||||
|
export async function importSkillFromFiles(input: {
|
||||||
|
readonly files: readonly SkillFileInput[];
|
||||||
|
readonly storeRoot: string;
|
||||||
|
}): Promise<ImportedSkillContent> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const file of input.files) {
|
||||||
|
validateSkillFilePath(file.path);
|
||||||
|
if (seen.has(file.path)) throw new Error(`duplicate skill file path: ${file.path}`);
|
||||||
|
seen.add(file.path);
|
||||||
|
}
|
||||||
|
return commitSkillContent({
|
||||||
|
storeRoot: input.storeRoot,
|
||||||
|
prepare: async (dir) => {
|
||||||
|
for (const file of input.files) {
|
||||||
|
const dest = join(dir, file.path);
|
||||||
|
const parent = join(dest, "..");
|
||||||
|
await mkdir(parent, { recursive: true, mode: 0o750 });
|
||||||
|
await writeFile(dest, file.bytes, { mode: 0o640 });
|
||||||
|
}
|
||||||
|
return inspectSkillDirectory(dir);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read all files from a stored skill version (by content digest). Returns
|
||||||
|
* relative paths and UTF-8 decoded content for the web editor.
|
||||||
|
*/
|
||||||
|
export async function readSkillFiles(input: {
|
||||||
|
readonly storeRoot: string;
|
||||||
|
readonly contentDigest: string;
|
||||||
|
}): Promise<readonly SkillFileOutput[]> {
|
||||||
|
if (!DIGEST_PATTERN.test(input.contentDigest)) {
|
||||||
|
throw new Error(`invalid content digest: ${input.contentDigest}`);
|
||||||
|
}
|
||||||
|
const dir = join(input.storeRoot, "versions", input.contentDigest);
|
||||||
|
const files: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
|
||||||
|
await walk(resolve(dir), resolve(dir), files);
|
||||||
|
return files.sort((a, b) => a.path.localeCompare(b.path)).map((f) => ({
|
||||||
|
path: f.path,
|
||||||
|
content: f.bytes.toString("utf8"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillFileInput {
|
||||||
|
readonly path: string;
|
||||||
|
readonly bytes: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillFileOutput {
|
||||||
|
readonly path: string;
|
||||||
|
readonly content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared commit logic: populate a temp dir, inspect it, deduplicate against
|
||||||
|
* an existing `versions/<digest>/` destination, and atomically rename.
|
||||||
|
*
|
||||||
|
* `prepare(dir)` writes the skill content into `dir` and returns the inspected
|
||||||
|
* result (name, description, contentDigest). The caller owns the write;
|
||||||
|
* commitSkillContent owns the move.
|
||||||
|
*/
|
||||||
|
async function commitSkillContent(input: {
|
||||||
|
readonly storeRoot: string;
|
||||||
|
readonly prepare: (dir: string) => Promise<ImportedSkillContent>;
|
||||||
|
}): Promise<ImportedSkillContent> {
|
||||||
const versionsRoot = join(input.storeRoot, "versions");
|
const versionsRoot = join(input.storeRoot, "versions");
|
||||||
await mkdir(versionsRoot, { recursive: true, mode: 0o750 });
|
await mkdir(versionsRoot, { recursive: true, mode: 0o750 });
|
||||||
|
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
|
||||||
|
|
||||||
|
let source: ImportedSkillContent;
|
||||||
|
try {
|
||||||
|
source = await input.prepare(temporary);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporary, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const destination = join(versionsRoot, source.contentDigest);
|
const destination = join(versionsRoot, source.contentDigest);
|
||||||
|
|
||||||
|
// If the destination already exists with identical content, reuse it.
|
||||||
try {
|
try {
|
||||||
const existing = await inspectSkillDirectory(destination);
|
const existing = await inspectSkillDirectory(destination);
|
||||||
if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) {
|
if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) {
|
||||||
throw new Error(`stored skill content digest mismatch: ${source.name}`);
|
throw new Error(`stored skill content digest mismatch: ${source.name}`);
|
||||||
}
|
}
|
||||||
|
await rm(temporary, { recursive: true, force: true });
|
||||||
return source;
|
return source;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingPath(error)) throw error;
|
if (!isMissingPath(error)) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
|
|
||||||
try {
|
try {
|
||||||
await cp(input.sourceDir, temporary, { recursive: true, force: false, errorOnExist: true });
|
|
||||||
const copied = await inspectSkillDirectory(temporary);
|
|
||||||
if (copied.contentDigest !== source.contentDigest || copied.name !== source.name) {
|
|
||||||
throw new Error(`skill changed while importing: ${source.name}`);
|
|
||||||
}
|
|
||||||
await rename(temporary, destination);
|
await rename(temporary, destination);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await rm(temporary, { recursive: true, force: true });
|
await rm(temporary, { recursive: true, force: true });
|
||||||
if (isDestinationExists(error)) {
|
if (isDestinationExists(error)) {
|
||||||
const existing = await inspectSkillDirectory(destination);
|
const existing = await inspectSkillDirectory(destination);
|
||||||
if (existing.contentDigest === source.contentDigest && existing.name === source.name) return source;
|
if (existing.contentDigest === source.contentDigest && existing.name === source.name) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateSkillFilePath(path: string): void {
|
||||||
|
if (path === "") throw new Error("skill file path is empty");
|
||||||
|
if (path.startsWith("/")) throw new Error(`skill file path must be relative: ${path}`);
|
||||||
|
if (path.includes("..")) throw new Error(`skill file path must not escape: ${path}`);
|
||||||
|
if (path.startsWith("\\")) throw new Error(`skill file path must be relative: ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function prepareRunSkillPlugin(input: {
|
export async function prepareRunSkillPlugin(input: {
|
||||||
readonly storeRoot: string;
|
readonly storeRoot: string;
|
||||||
readonly runId: string;
|
readonly runId: string;
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Provider model catalog: fetches the list of models available to an
|
||||||
|
* Organization from its ACTIVE provider connection (OpenRouter), with an
|
||||||
|
* in-memory TTL cache so the admin model picker stays responsive without
|
||||||
|
* hammering the upstream API on every page load.
|
||||||
|
*
|
||||||
|
* The model list is **derived** from the provider connection — there is no
|
||||||
|
* separate model table to maintain. When an org activates a provider, its
|
||||||
|
* models become selectable; when the provider is disabled, the list empties.
|
||||||
|
*/
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||||
|
import { decryptStoredProviderCredential } from "./providerConnections.js";
|
||||||
|
|
||||||
|
/** A model entry surfaced to the admin model picker. */
|
||||||
|
export interface ProviderModelEntry {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly toolCapable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model entry as returned by OpenRouter's /v1/models endpoint. */
|
||||||
|
interface OpenRouterModel {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly supported_parameters: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenRouterModelsResponse {
|
||||||
|
readonly data: readonly OpenRouterModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache entry: models + expiry timestamp. */
|
||||||
|
interface CacheEntry {
|
||||||
|
readonly models: readonly ProviderModelEntry[];
|
||||||
|
readonly expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-organization model catalog cache. Keyed by organizationId so that
|
||||||
|
* multiple orgs in the same Silo don't cross-pollute. The cache is intentionally
|
||||||
|
* process-local: it survives for the lifetime of the Hub process and is
|
||||||
|
* invalidated by TTL, not by DB events. A cache miss re-fetches from the
|
||||||
|
* provider API.
|
||||||
|
*/
|
||||||
|
export class ProviderModelCatalog {
|
||||||
|
private readonly cache = new Map<string, CacheEntry>();
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly secrets: LocalSecretEnvelope,
|
||||||
|
private readonly ttlMs: number = DEFAULT_TTL_MS,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
) {
|
||||||
|
this.fetchImpl = fetchImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List tool-capable models available to the organization's ACTIVE provider
|
||||||
|
* connection. Returns cached results when fresh; otherwise fetches from the
|
||||||
|
* provider API. Falls back to an empty list (not an error) when the org has
|
||||||
|
* no ACTIVE provider — the admin sees the env-default model from the
|
||||||
|
* separate env fallback in the route.
|
||||||
|
*/
|
||||||
|
async listModels(organizationId: string): Promise<readonly ProviderModelEntry[]> {
|
||||||
|
const cached = this.cache.get(organizationId);
|
||||||
|
if (cached !== undefined && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.models;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await this.resolveActiveProviderCredential(organizationId);
|
||||||
|
if (credential === null) return [];
|
||||||
|
|
||||||
|
const models = await this.fetchModelsFromProvider(credential);
|
||||||
|
this.cache.set(organizationId, {
|
||||||
|
models,
|
||||||
|
expiresAt: Date.now() + this.ttlMs,
|
||||||
|
});
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force a cache invalidation (e.g. after provider connection changes). */
|
||||||
|
invalidate(organizationId: string): void {
|
||||||
|
this.cache.delete(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveActiveProviderCredential(
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<{ readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string } | null> {
|
||||||
|
const connection = await this.prisma.organizationProviderConnection.findFirst({
|
||||||
|
where: { organizationId, status: "ACTIVE" },
|
||||||
|
include: { activeSecretVersion: true },
|
||||||
|
});
|
||||||
|
const version = connection?.activeSecretVersion;
|
||||||
|
if (connection === null || version === null || version === undefined || version.retiredAt !== null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = decryptStoredProviderCredential(this.secrets, {
|
||||||
|
organizationId,
|
||||||
|
connectionId: connection.id,
|
||||||
|
providerId: connection.providerId,
|
||||||
|
secretVersionId: version.id,
|
||||||
|
envelopeVersion: version.envelopeVersion,
|
||||||
|
keyId: version.keyId,
|
||||||
|
envelope: version.envelope,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
baseUrl: payload.baseUrl,
|
||||||
|
authToken: payload.authToken,
|
||||||
|
anthropicApiKey: payload.anthropicApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchModelsFromProvider(
|
||||||
|
credential: { readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string },
|
||||||
|
): Promise<readonly ProviderModelEntry[]> {
|
||||||
|
const base = credential.baseUrl.endsWith("/") ? credential.baseUrl : `${credential.baseUrl}/`;
|
||||||
|
// Filter to models that support tool calling — the agent runner requires it.
|
||||||
|
const url = new URL("v1/models?supported_parameters=tools", base);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
authorization: `Bearer ${credential.authToken}`,
|
||||||
|
accept: "application/json",
|
||||||
|
};
|
||||||
|
if (credential.anthropicApiKey !== "") headers["x-api-key"] = credential.anthropicApiKey;
|
||||||
|
|
||||||
|
const response = await this.fetchImpl(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`provider models request failed: status ${response.status}`);
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as OpenRouterModelsResponse;
|
||||||
|
return body.data.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
label: m.name,
|
||||||
|
toolCapable: m.supported_parameters.includes("tools"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ const TOOL_ICONS: Record<string, string> = {
|
|||||||
request_approval: "thumb-up-filled",
|
request_approval: "thumb-up-filled",
|
||||||
feishu_read_context: "search-filled",
|
feishu_read_context: "search-filled",
|
||||||
feishu_download_resource: "download-filled",
|
feishu_download_resource: "download-filled",
|
||||||
|
webfetch: "link-copy-filled",
|
||||||
|
websearch: "search-filled",
|
||||||
};
|
};
|
||||||
|
|
||||||
function toolIcon(toolName: string): string {
|
function toolIcon(toolName: string): string {
|
||||||
|
|||||||
@@ -182,18 +182,18 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
|||||||
tools.push(
|
tools.push(
|
||||||
tool(
|
tool(
|
||||||
"request_approval",
|
"request_approval",
|
||||||
"Send an interactive Feishu approval/confirmation card to the current chat and wait for a button click.",
|
"Send an interactive Feishu approval/confirmation card in the current chat and wait for the user's button click.",
|
||||||
{
|
{
|
||||||
title: z.string().describe("Card title."),
|
title: z.string().describe("Card title."),
|
||||||
body: z.string().describe("Markdown card body explaining what needs approval or confirmation."),
|
body: z.string().describe("Markdown card body explaining what needs approval or confirmation."),
|
||||||
options: z
|
options: z
|
||||||
.array(z.object({
|
.array(z.object({
|
||||||
label: z.string().describe("Button label shown to the user."),
|
label: z.string().describe("Button label shown to the user."),
|
||||||
value: z.string().describe("Action value returned to the agent when this button is clicked."),
|
value: z.string().describe("Stable option value returned after the user clicks."),
|
||||||
style: z.enum(["primary", "default", "danger"]).optional().describe("Optional Feishu button style."),
|
style: z.enum(["default", "primary", "danger"]).optional(),
|
||||||
}))
|
}))
|
||||||
.min(1)
|
.min(1)
|
||||||
.describe("Button choices for the approval card."),
|
.describe("One or more response options."),
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args) => {
|
||||||
const messageId = await sendApprovalCard(
|
const messageId = await sendApprovalCard(
|
||||||
|
|||||||
@@ -92,7 +92,18 @@ export function createSlashCommandRegistry(
|
|||||||
finishedAt: { not: null },
|
finishedAt: { not: null },
|
||||||
},
|
},
|
||||||
orderBy: { finishedAt: "asc" },
|
orderBy: { finishedAt: "asc" },
|
||||||
select: { model: true, provider: true, inputTokens: true, outputTokens: true, costUsd: true },
|
select: {
|
||||||
|
usageFacts: {
|
||||||
|
select: {
|
||||||
|
provider: true,
|
||||||
|
model: true,
|
||||||
|
inputTokens: true,
|
||||||
|
outputTokens: true,
|
||||||
|
costUsd: true,
|
||||||
|
},
|
||||||
|
orderBy: { occurredAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions);
|
await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions);
|
||||||
},
|
},
|
||||||
@@ -118,18 +129,24 @@ async function currentRoleSessionIds(
|
|||||||
return sessions.map((session) => session.id);
|
return sessions.map((session) => session.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsageRun {
|
interface UsageRunWithFacts {
|
||||||
readonly model: string;
|
readonly usageFacts: readonly {
|
||||||
readonly provider: string;
|
readonly provider: string;
|
||||||
readonly inputTokens: number | null;
|
readonly model: string | null;
|
||||||
readonly outputTokens: number | null;
|
readonly inputTokens: number | null;
|
||||||
readonly costUsd: unknown;
|
readonly outputTokens: number | null;
|
||||||
|
readonly costUsd: unknown;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "project"): string {
|
function formatUsageReport(runs: readonly UsageRunWithFacts[], scope: "current" | "project"): string {
|
||||||
if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`;
|
if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`;
|
||||||
|
// Bucket by (fact.provider, fact.model) — ADR-0026: external capabilities
|
||||||
|
// carry their own provider/model and contribute their own meter, so a run
|
||||||
|
// with a main loop + an external call lands in two buckets. A run with no
|
||||||
|
// cost-bearing fact is "unrecorded" (ADR-0022: missing cost ≠ zero).
|
||||||
const buckets = new Map<string, {
|
const buckets = new Map<string, {
|
||||||
provider: string; model: string; runs: number; inputTokens: number; outputTokens: number; costUsd: number;
|
provider: string; model: string; facts: number; inputTokens: number; outputTokens: number; costUsd: number;
|
||||||
}>();
|
}>();
|
||||||
let recordedRuns = 0;
|
let recordedRuns = 0;
|
||||||
let unrecordedRuns = 0;
|
let unrecordedRuns = 0;
|
||||||
@@ -137,21 +154,34 @@ function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "projec
|
|||||||
let totalOutputTokens = 0;
|
let totalOutputTokens = 0;
|
||||||
let totalCostUsd = 0;
|
let totalCostUsd = 0;
|
||||||
for (const run of runs) {
|
for (const run of runs) {
|
||||||
const costUsd = decimalToNumberOrNull(run.costUsd);
|
const facts = run.usageFacts;
|
||||||
if (costUsd === null) { unrecordedRuns++; continue; }
|
if (facts.length === 0 || !facts.some((f) => f.costUsd !== null && f.costUsd !== undefined)) {
|
||||||
|
unrecordedRuns++;
|
||||||
|
// Tokens from unrecorded runs still count toward totals (matches pre-0026).
|
||||||
|
for (const f of facts) {
|
||||||
|
totalInputTokens += f.inputTokens ?? 0;
|
||||||
|
totalOutputTokens += f.outputTokens ?? 0;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
recordedRuns++;
|
recordedRuns++;
|
||||||
const key = `${run.provider}\u0000${run.model}`;
|
for (const f of facts) {
|
||||||
const bucket = buckets.get(key) ?? {
|
const costUsd = decimalToNumberOrNull(f.costUsd);
|
||||||
provider: run.provider, model: run.model, runs: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
|
if (costUsd === null) continue;
|
||||||
};
|
const model = f.model ?? "(unknown model)";
|
||||||
bucket.runs++;
|
const key = `${f.provider}\u0000${model}`;
|
||||||
bucket.inputTokens += run.inputTokens ?? 0;
|
const bucket = buckets.get(key) ?? {
|
||||||
bucket.outputTokens += run.outputTokens ?? 0;
|
provider: f.provider, model, facts: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
|
||||||
bucket.costUsd += costUsd;
|
};
|
||||||
buckets.set(key, bucket);
|
bucket.facts += 1;
|
||||||
totalInputTokens += run.inputTokens ?? 0;
|
bucket.inputTokens += f.inputTokens ?? 0;
|
||||||
totalOutputTokens += run.outputTokens ?? 0;
|
bucket.outputTokens += f.outputTokens ?? 0;
|
||||||
totalCostUsd += costUsd;
|
bucket.costUsd += costUsd;
|
||||||
|
buckets.set(key, bucket);
|
||||||
|
totalInputTokens += f.inputTokens ?? 0;
|
||||||
|
totalOutputTokens += f.outputTokens ?? 0;
|
||||||
|
totalCostUsd += costUsd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const lines = [
|
const lines = [
|
||||||
`${scope === "current" ? "当前角色会话" : "当前项目"}用量`,
|
`${scope === "current" ? "当前角色会话" : "当前项目"}用量`,
|
||||||
@@ -160,7 +190,7 @@ function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "projec
|
|||||||
];
|
];
|
||||||
if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`);
|
if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`);
|
||||||
for (const bucket of buckets.values()) {
|
for (const bucket of buckets.values()) {
|
||||||
lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}`);
|
lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.facts)} 次, ${formatUsd(bucket.costUsd)}`);
|
||||||
}
|
}
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -604,6 +604,32 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
data: { metadata: mergeSessionMetadata(session.metadata, metadataPatch) },
|
data: { metadata: mergeSessionMetadata(session.metadata, metadataPatch) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// ADR-0026: the UsageFact ledger is the truth; AgentRun.costUsd /
|
||||||
|
// inputTokens / outputTokens are a derived rollup cache for existing
|
||||||
|
// readers. We write the fact first, then mirror it onto the run.
|
||||||
|
// They are separate statements (not one transaction) so the AgentRun
|
||||||
|
// row lock is held for the shortest possible window and concurrent
|
||||||
|
// workspace teardown cannot deadlock on the FK ShareLock. A crash
|
||||||
|
// between the two leaves the cache stale, but the cache is derived
|
||||||
|
// (the usage service re-reads facts), so staleness is recoverable;
|
||||||
|
// the reverse order would lose the truth and is unrecoverable.
|
||||||
|
const finishedAt = new Date();
|
||||||
|
const reportedCost = result.costUsd;
|
||||||
|
const costSource = reportedCost !== undefined ? "provider_reported" : "unknown";
|
||||||
|
await deps.prisma.usageFact.create({
|
||||||
|
data: {
|
||||||
|
runId: run.id,
|
||||||
|
occurredAt: finishedAt,
|
||||||
|
kind: "model_completion",
|
||||||
|
provider: providerId,
|
||||||
|
model,
|
||||||
|
inputTokens: result.usage.inputTokens,
|
||||||
|
outputTokens: result.usage.outputTokens,
|
||||||
|
costUsd: reportedCost ?? null,
|
||||||
|
costSource,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
await deps.prisma.agentRun.update({
|
await deps.prisma.agentRun.update({
|
||||||
where: { id: run.id },
|
where: { id: run.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -618,9 +644,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
: "FAILED",
|
: "FAILED",
|
||||||
inputTokens: result.usage.inputTokens,
|
inputTokens: result.usage.inputTokens,
|
||||||
outputTokens: result.usage.outputTokens,
|
outputTokens: result.usage.outputTokens,
|
||||||
...(result.costUsd !== undefined ? { costUsd: result.costUsd, costSource: "provider_reported" } : {}),
|
costUsd: reportedCost ?? null,
|
||||||
|
costSource,
|
||||||
error: result.error ?? null,
|
error: result.error ?? null,
|
||||||
finishedAt: new Date(),
|
finishedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeAudit(deps.prisma, {
|
await writeAudit(deps.prisma, {
|
||||||
|
|||||||
@@ -73,9 +73,28 @@ export async function getSessionDetail(
|
|||||||
inputTokens: true,
|
inputTokens: true,
|
||||||
outputTokens: true,
|
outputTokens: true,
|
||||||
costUsd: true,
|
costUsd: true,
|
||||||
|
costSource: true,
|
||||||
startedAt: true,
|
startedAt: true,
|
||||||
finishedAt: true,
|
finishedAt: true,
|
||||||
error: true,
|
error: true,
|
||||||
|
usageFacts: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
occurredAt: true,
|
||||||
|
kind: true,
|
||||||
|
provider: true,
|
||||||
|
model: true,
|
||||||
|
inputTokens: true,
|
||||||
|
outputTokens: true,
|
||||||
|
quantity: true,
|
||||||
|
unit: true,
|
||||||
|
costUsd: true,
|
||||||
|
costSource: true,
|
||||||
|
capabilityId: true,
|
||||||
|
correlationId: true,
|
||||||
|
},
|
||||||
|
orderBy: { occurredAt: "asc" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: { startedAt: "desc" },
|
orderBy: { startedAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
@@ -106,9 +125,25 @@ export async function getSessionDetail(
|
|||||||
inputTokens: r.inputTokens,
|
inputTokens: r.inputTokens,
|
||||||
outputTokens: r.outputTokens,
|
outputTokens: r.outputTokens,
|
||||||
costUsd: r.costUsd === null ? null : Number(r.costUsd),
|
costUsd: r.costUsd === null ? null : Number(r.costUsd),
|
||||||
|
costSource: r.costSource,
|
||||||
startedAt: r.startedAt.toISOString(),
|
startedAt: r.startedAt.toISOString(),
|
||||||
finishedAt: r.finishedAt?.toISOString() ?? null,
|
finishedAt: r.finishedAt?.toISOString() ?? null,
|
||||||
error: r.error,
|
error: r.error,
|
||||||
|
usageFacts: r.usageFacts.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
occurredAt: f.occurredAt.toISOString(),
|
||||||
|
kind: f.kind,
|
||||||
|
provider: f.provider,
|
||||||
|
model: f.model,
|
||||||
|
inputTokens: f.inputTokens,
|
||||||
|
outputTokens: f.outputTokens,
|
||||||
|
quantity: f.quantity === null ? null : Number(f.quantity),
|
||||||
|
unit: f.unit,
|
||||||
|
costUsd: f.costUsd === null ? null : Number(f.costUsd),
|
||||||
|
costSource: f.costSource,
|
||||||
|
capabilityId: f.capabilityId,
|
||||||
|
correlationId: f.correlationId,
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-17
@@ -1,8 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Org / project usage rollups from AgentRun cost facts (ADR-0021).
|
* Org / project usage rollups from the UsageFact ledger (ADR-0021, ADR-0026).
|
||||||
* Operational usage accounting, not payment collection. Organizations may use
|
* Operational usage accounting, not payment collection. Organizations may use
|
||||||
* either their own provider credentials or a distinct platform-managed
|
* either their own provider credentials or a distinct platform-managed
|
||||||
* connection; commercial billing remains outside the pilot scope.
|
* connection; commercial billing remains outside the pilot scope.
|
||||||
|
*
|
||||||
|
* The truth is in `UsageFact`; `AgentRun.costUsd / inputTokens / outputTokens`
|
||||||
|
* are a derived rollup cache. This service reads `UsageFact` directly so that
|
||||||
|
* external-capability consumption (PDF→MD, ASR, …) is attributed correctly,
|
||||||
|
* not just the main model loop. A run with no cost-bearing fact is
|
||||||
|
* `runsWithoutCost` — ADR-0022: missing cost ≠ zero.
|
||||||
*/
|
*/
|
||||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
@@ -32,6 +38,53 @@ export interface UsageReport {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FactRow = {
|
||||||
|
readonly inputTokens: number | null;
|
||||||
|
readonly outputTokens: number | null;
|
||||||
|
readonly costUsd: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunWithFacts = {
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly usageFacts: readonly FactRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A run is "recorded with cost" if any of its facts carries a known costUsd. */
|
||||||
|
function runHasRecordedCost(facts: readonly FactRow[]): boolean {
|
||||||
|
return facts.some((f) => f.costUsd !== null && f.costUsd !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decimal→number for Prisma Decimal; null/undefined → null. */
|
||||||
|
function factCostUsdToNumber(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunRollup {
|
||||||
|
readonly hasCost: boolean;
|
||||||
|
readonly inputTokens: number;
|
||||||
|
readonly outputTokens: number;
|
||||||
|
readonly costUsd: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollupRun(facts: readonly FactRow[]): RunRollup {
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
let costUsd: number | null = null;
|
||||||
|
let hasCost = false;
|
||||||
|
for (const f of facts) {
|
||||||
|
inputTokens += f.inputTokens ?? 0;
|
||||||
|
outputTokens += f.outputTokens ?? 0;
|
||||||
|
const c = factCostUsdToNumber(f.costUsd);
|
||||||
|
if (c !== null) {
|
||||||
|
hasCost = true;
|
||||||
|
costUsd = (costUsd ?? 0) + c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { hasCost, inputTokens, outputTokens, costUsd };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getOrgUsage(
|
export async function getOrgUsage(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
input: {
|
input: {
|
||||||
@@ -54,8 +107,9 @@ export async function getOrgUsage(
|
|||||||
return emptyReport(input.from, input.to);
|
return emptyReport(input.from, input.to);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectIds = projects.map((p) => p.id);
|
||||||
const runWhere: Prisma.AgentRunWhereInput = {
|
const runWhere: Prisma.AgentRunWhereInput = {
|
||||||
projectId: { in: projects.map((p) => p.id) },
|
projectId: { in: projectIds },
|
||||||
...(input.from !== undefined || input.to !== undefined
|
...(input.from !== undefined || input.to !== undefined
|
||||||
? {
|
? {
|
||||||
startedAt: {
|
startedAt: {
|
||||||
@@ -66,20 +120,25 @@ export async function getOrgUsage(
|
|||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Date range filters by run.startedAt, matching the pre-ADR-0026 semantics:
|
||||||
|
// a run is in or out of the window based on when it started, and all of its
|
||||||
|
// facts come along. Filtering facts by occurredAt independently would let a
|
||||||
|
// run contribute partial cost to a window it doesn't belong to.
|
||||||
const runs = await prisma.agentRun.findMany({
|
const runs = await prisma.agentRun.findMany({
|
||||||
where: runWhere,
|
where: runWhere,
|
||||||
select: {
|
select: {
|
||||||
projectId: true,
|
projectId: true,
|
||||||
inputTokens: true,
|
usageFacts: {
|
||||||
outputTokens: true,
|
select: { inputTokens: true, outputTokens: true, costUsd: true },
|
||||||
costUsd: true,
|
orderBy: { occurredAt: "asc" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type MutableRow = {
|
type MutableRow = {
|
||||||
projectId: string;
|
readonly projectId: string;
|
||||||
projectName: string;
|
readonly projectName: string;
|
||||||
folderId: string | null;
|
readonly folderId: string | null;
|
||||||
runCount: number;
|
runCount: number;
|
||||||
runsWithCost: number;
|
runsWithCost: number;
|
||||||
runsWithoutCost: number;
|
runsWithoutCost: number;
|
||||||
@@ -103,22 +162,33 @@ export async function getOrgUsage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const run of runs) {
|
for (const run of runs as readonly RunWithFacts[]) {
|
||||||
const row = byProject.get(run.projectId);
|
const row = byProject.get(run.projectId);
|
||||||
if (row === undefined) continue;
|
if (row === undefined) continue;
|
||||||
|
const roll = rollupRun(run.usageFacts);
|
||||||
row.runCount += 1;
|
row.runCount += 1;
|
||||||
row.inputTokens += run.inputTokens ?? 0;
|
if (roll.hasCost) {
|
||||||
row.outputTokens += run.outputTokens ?? 0;
|
|
||||||
if (run.costUsd === null) {
|
|
||||||
row.runsWithoutCost += 1;
|
|
||||||
} else {
|
|
||||||
row.runsWithCost += 1;
|
row.runsWithCost += 1;
|
||||||
const cost = Number(run.costUsd);
|
row.costUsd = (row.costUsd ?? 0) + (roll.costUsd ?? 0);
|
||||||
row.costUsd = (row.costUsd ?? 0) + cost;
|
} else {
|
||||||
|
row.runsWithoutCost += 1;
|
||||||
}
|
}
|
||||||
|
row.inputTokens += roll.inputTokens;
|
||||||
|
row.outputTokens += roll.outputTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectsOut: ProjectUsageRow[] = [...byProject.values()];
|
const projectsOut: ProjectUsageRow[] = [...byProject.values()].map((r) => ({
|
||||||
|
projectId: r.projectId,
|
||||||
|
projectName: r.projectName,
|
||||||
|
folderId: r.folderId,
|
||||||
|
runCount: r.runCount,
|
||||||
|
runsWithCost: r.runsWithCost,
|
||||||
|
runsWithoutCost: r.runsWithoutCost,
|
||||||
|
inputTokens: r.inputTokens,
|
||||||
|
outputTokens: r.outputTokens,
|
||||||
|
costUsd: r.costUsd,
|
||||||
|
}));
|
||||||
|
|
||||||
let runCount = 0;
|
let runCount = 0;
|
||||||
let runsWithCost = 0;
|
let runsWithCost = 0;
|
||||||
let runsWithoutCost = 0;
|
let runsWithoutCost = 0;
|
||||||
|
|||||||
@@ -182,12 +182,16 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
|
|||||||
throw new Error(`organization ${project.organization.id} must have exactly one active default Agent role`);
|
throw new Error(`organization ${project.organization.id} must have exactly one active default Agent role`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The model list is the env-based fallback used when a role has no
|
||||||
|
// explicit defaultModel. Role defaultModels are chosen from the provider
|
||||||
|
// model catalog (admin surface) and may not be in the env list — we don't
|
||||||
|
// validate against it at runtime; the admin already validated by selection.
|
||||||
const defaults = await this.envSettings.modelRegistry(scope);
|
const defaults = await this.envSettings.modelRegistry(scope);
|
||||||
const enabledModels = new Set(defaults.listModels().map((model) => model.id));
|
const fallbackModels = defaults.listModels();
|
||||||
const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({
|
const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({
|
||||||
id: role.roleId,
|
id: role.roleId,
|
||||||
label: role.label,
|
label: role.label,
|
||||||
defaultModel: validateRoleModel(role.roleId, role.defaultModel, enabledModels),
|
defaultModel: role.defaultModel ?? undefined,
|
||||||
...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}),
|
...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}),
|
||||||
...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}),
|
...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}),
|
||||||
skills: role.skillBindings.map((binding): RoleSkillEntry => {
|
skills: role.skillBindings.map((binding): RoleSkillEntry => {
|
||||||
@@ -210,7 +214,7 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
return new InMemoryModelRegistry(defaults.listModels(), roles);
|
return new InMemoryModelRegistry(fallbackModels, roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
||||||
@@ -225,15 +229,6 @@ function roleToolsFromJson(roleId: string, value: Prisma.JsonValue): readonly st
|
|||||||
return value as string[];
|
return value as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRoleModel(
|
|
||||||
roleId: string,
|
|
||||||
model: string | null,
|
|
||||||
enabledModels: ReadonlySet<string>,
|
|
||||||
): string | undefined {
|
|
||||||
if (model === null) return undefined;
|
|
||||||
if (!enabledModels.has(model)) throw new Error(`role ${roleId} selects unavailable model ${model}`);
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadActiveProviderSecret(
|
async function loadActiveProviderSecret(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { prisma, resetDb, seedTestOrganization, DEFAULT_ORG_ID } from "./helpers.js";
|
||||||
|
import { getOrgUsage, getProjectUsage } from "../../src/org/usage.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR-0026: org/project usage rolls up from the UsageFact ledger, not from the
|
||||||
|
* AgentRun scalar cache. These tests pin the fact-based aggregation contract:
|
||||||
|
* - a run with a cost-bearing fact → runsWithCost, costUsd summed
|
||||||
|
* - a run whose facts all have null costUsd → runsWithoutCost (≠ zero)
|
||||||
|
* - non-token meter (quantity/unit) is stored but does not corrupt token sums
|
||||||
|
* - multiple facts per run (main loop + external capability) are summed together
|
||||||
|
* - the AgentRun rollup cache is NOT consulted by getOrgUsage
|
||||||
|
*/
|
||||||
|
describe("usage rollup from UsageFact (ADR-0026)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
await seedTestOrganization();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedRun(opts: {
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly projectName: string;
|
||||||
|
readonly folderId?: string;
|
||||||
|
readonly facts: ReadonlyArray<{
|
||||||
|
readonly kind?: string;
|
||||||
|
readonly provider?: string;
|
||||||
|
readonly model?: string;
|
||||||
|
readonly inputTokens?: number;
|
||||||
|
readonly outputTokens?: number;
|
||||||
|
readonly costUsd?: number | null;
|
||||||
|
readonly costSource?: string;
|
||||||
|
readonly capabilityId?: string;
|
||||||
|
readonly quantity?: number | null;
|
||||||
|
readonly unit?: string | null;
|
||||||
|
}>;
|
||||||
|
}): Promise<void> {
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
id: opts.projectId,
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
name: opts.projectName,
|
||||||
|
workspaceDir: `/tmp/test-${opts.projectId}`,
|
||||||
|
...(opts.folderId !== undefined ? { folderId: opts.folderId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const startedAt = new Date("2026-07-18T10:00:00Z");
|
||||||
|
const finishedAt = new Date("2026-07-18T10:05:00Z");
|
||||||
|
const run = await prisma.agentRun.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
entrypoint: "FEISHU",
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "mock-model",
|
||||||
|
metadata: {},
|
||||||
|
status: "COMPLETED",
|
||||||
|
prompt: "test",
|
||||||
|
startedAt,
|
||||||
|
finishedAt,
|
||||||
|
// Deliberately set the rollup cache to values that differ from the
|
||||||
|
// facts, to prove getOrgUsage reads facts, not the cache.
|
||||||
|
inputTokens: 999,
|
||||||
|
outputTokens: 999,
|
||||||
|
costUsd: 999,
|
||||||
|
costSource: "provider_reported",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const [i, f] of opts.facts.entries()) {
|
||||||
|
await prisma.usageFact.create({
|
||||||
|
data: {
|
||||||
|
runId: run.id,
|
||||||
|
occurredAt: new Date(startedAt.getTime() + i * 1000),
|
||||||
|
kind: f.kind ?? "model_completion",
|
||||||
|
provider: f.provider ?? "openrouter",
|
||||||
|
model: f.model ?? "mock-model",
|
||||||
|
inputTokens: f.inputTokens ?? null,
|
||||||
|
outputTokens: f.outputTokens ?? null,
|
||||||
|
costUsd: f.costUsd ?? null,
|
||||||
|
costSource: f.costSource ?? (f.costUsd !== undefined && f.costUsd !== null ? "provider_reported" : "unknown"),
|
||||||
|
capabilityId: f.capabilityId ?? null,
|
||||||
|
quantity: f.quantity ?? null,
|
||||||
|
unit: f.unit ?? null,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sums cost and tokens from facts, ignoring the AgentRun rollup cache", async () => {
|
||||||
|
await seedRun({
|
||||||
|
projectId: "proj-a",
|
||||||
|
projectName: "A",
|
||||||
|
facts: [{ inputTokens: 10, outputTokens: 5, costUsd: 0.0023 }],
|
||||||
|
});
|
||||||
|
await seedRun({
|
||||||
|
projectId: "proj-b",
|
||||||
|
projectName: "B",
|
||||||
|
facts: [{ inputTokens: 20, outputTokens: 10, costUsd: 0.01 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
|
||||||
|
|
||||||
|
expect(report.totals.runCount).toBe(2);
|
||||||
|
expect(report.totals.runsWithCost).toBe(2);
|
||||||
|
expect(report.totals.runsWithoutCost).toBe(0);
|
||||||
|
expect(report.totals.inputTokens).toBe(30);
|
||||||
|
expect(report.totals.outputTokens).toBe(15);
|
||||||
|
expect(report.totals.costUsd).toBeCloseTo(0.0123, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a run with only null-cost facts as runsWithoutCost, never zero", async () => {
|
||||||
|
await seedRun({
|
||||||
|
projectId: "proj-unknown",
|
||||||
|
projectName: "Unknown",
|
||||||
|
facts: [{ inputTokens: 7, outputTokens: 3, costUsd: null, costSource: "unknown" }],
|
||||||
|
});
|
||||||
|
await seedRun({
|
||||||
|
projectId: "proj-known",
|
||||||
|
projectName: "Known",
|
||||||
|
facts: [{ inputTokens: 4, outputTokens: 2, costUsd: 0.05 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
|
||||||
|
|
||||||
|
expect(report.totals.runCount).toBe(2);
|
||||||
|
expect(report.totals.runsWithCost).toBe(1);
|
||||||
|
expect(report.totals.runsWithoutCost).toBe(1);
|
||||||
|
// The unknown-cost run still contributes its tokens.
|
||||||
|
expect(report.totals.inputTokens).toBe(11);
|
||||||
|
expect(report.totals.outputTokens).toBe(5);
|
||||||
|
// And its cost is NOT counted as zero — only the known cost is summed.
|
||||||
|
expect(report.totals.costUsd).toBeCloseTo(0.05, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums multiple facts per run (main loop + external capability)", async () => {
|
||||||
|
await seedRun({
|
||||||
|
projectId: "proj-multi",
|
||||||
|
projectName: "Multi",
|
||||||
|
facts: [
|
||||||
|
{ kind: "model_completion", inputTokens: 100, outputTokens: 50, costUsd: 0.02 },
|
||||||
|
{
|
||||||
|
kind: "external_capability",
|
||||||
|
provider: "mineru",
|
||||||
|
model: null,
|
||||||
|
inputTokens: null,
|
||||||
|
outputTokens: null,
|
||||||
|
costUsd: 0.015,
|
||||||
|
costSource: "provider_reported",
|
||||||
|
capabilityId: "pdf_to_md_bundle",
|
||||||
|
quantity: 12,
|
||||||
|
unit: "pages",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
|
||||||
|
expect(report.totals.runCount).toBe(1);
|
||||||
|
expect(report.totals.runsWithCost).toBe(1);
|
||||||
|
expect(report.totals.inputTokens).toBe(100);
|
||||||
|
expect(report.totals.outputTokens).toBe(50);
|
||||||
|
expect(report.totals.costUsd).toBeCloseTo(0.035, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a run with no facts at all is runsWithoutCost and contributes nothing", async () => {
|
||||||
|
await seedRun({ projectId: "proj-empty", projectName: "Empty", facts: [] });
|
||||||
|
|
||||||
|
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
|
||||||
|
expect(report.totals.runCount).toBe(1);
|
||||||
|
expect(report.totals.runsWithCost).toBe(0);
|
||||||
|
expect(report.totals.runsWithoutCost).toBe(1);
|
||||||
|
expect(report.totals.inputTokens).toBe(0);
|
||||||
|
expect(report.totals.outputTokens).toBe(0);
|
||||||
|
expect(report.totals.costUsd).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getProjectUsage returns just that project's row", async () => {
|
||||||
|
await seedRun({ projectId: "proj-x", projectName: "X", facts: [{ costUsd: 0.1, inputTokens: 1, outputTokens: 1 }] });
|
||||||
|
await seedRun({ projectId: "proj-y", projectName: "Y", facts: [{ costUsd: 0.2, inputTokens: 2, outputTokens: 2 }] });
|
||||||
|
|
||||||
|
const row = await getProjectUsage(prisma, { organizationId: DEFAULT_ORG_ID, projectId: "proj-x" });
|
||||||
|
expect(row.projectId).toBe("proj-x");
|
||||||
|
expect(row.runCount).toBe(1);
|
||||||
|
expect(row.costUsd).toBeCloseTo(0.1, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty report for an org with no projects", async () => {
|
||||||
|
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
|
||||||
|
expect(report.projects).toHaveLength(0);
|
||||||
|
expect(report.totals.runCount).toBe(0);
|
||||||
|
expect(report.totals.costUsd).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -290,6 +290,9 @@ function mockPrisma(): PrismaClient {
|
|||||||
findUnique: vi.fn(async () => null),
|
findUnique: vi.fn(async () => null),
|
||||||
update: vi.fn(async () => ({ id: "run-1" })),
|
update: vi.fn(async () => ({ id: "run-1" })),
|
||||||
},
|
},
|
||||||
|
usageFact: {
|
||||||
|
create: vi.fn(async () => ({ id: "fact-1" })),
|
||||||
|
},
|
||||||
projectAgentLock: {
|
projectAgentLock: {
|
||||||
findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })),
|
findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })),
|
||||||
create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => {
|
create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => {
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { ProviderModelCatalog, type ProviderModelEntry } from "../../src/connections/providerModelCatalog.js";
|
||||||
|
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
|
||||||
|
const TEST_KEY = Buffer.alloc(32, "k");
|
||||||
|
const secrets = new LocalSecretEnvelope({
|
||||||
|
activeKeyId: "test-active",
|
||||||
|
keys: new Map([["test-active", TEST_KEY]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeEncryptedPayload(baseUrl: string, authToken: string, anthropicApiKey = "") {
|
||||||
|
const payload = { schemaVersion: 1, baseUrl, authToken, anthropicApiKey };
|
||||||
|
return secrets.encryptJson({ purpose: "provider-connection", organizationId: "org-1", connectionId: "conn-1", secretVersionId: "sv-1" }, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockPrismaWithConnection(overrides: Partial<{
|
||||||
|
status: string;
|
||||||
|
retiredAt: Date | null;
|
||||||
|
providerId: string;
|
||||||
|
}> = {}) {
|
||||||
|
const envelope = makeEncryptedPayload("https://openrouter.ai/api", "sk-test-key", "sk-ant-test");
|
||||||
|
return {
|
||||||
|
organizationProviderConnection: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({
|
||||||
|
id: "conn-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
providerId: overrides.providerId ?? "openrouter",
|
||||||
|
status: overrides.status ?? "ACTIVE",
|
||||||
|
activeSecretVersion: {
|
||||||
|
id: "sv-1",
|
||||||
|
connectionId: "conn-1",
|
||||||
|
envelopeVersion: 1,
|
||||||
|
keyId: "test-active",
|
||||||
|
envelope,
|
||||||
|
retiredAt: overrides.retiredAt ?? null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetchResponse(models: Array<{ id: string; name: string; supported_parameters: string[] }>) {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ data: models }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProviderModelCatalog", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches tool-capable models from the provider API", async () => {
|
||||||
|
const prisma = mockPrismaWithConnection();
|
||||||
|
const fetchImpl = mockFetchResponse([
|
||||||
|
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools", "temperature"] },
|
||||||
|
{ id: "openai/gpt-4o", name: "GPT-4o", supported_parameters: ["tools", "temperature"] },
|
||||||
|
{ id: "meta/llama-3", name: "Llama 3", supported_parameters: ["temperature"] },
|
||||||
|
]);
|
||||||
|
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||||
|
|
||||||
|
const models = await catalog.listModels("org-1");
|
||||||
|
|
||||||
|
expect(models).toHaveLength(3);
|
||||||
|
expect(models.map((m: ProviderModelEntry) => m.id)).toEqual([
|
||||||
|
"anthropic/claude-sonnet-5",
|
||||||
|
"openai/gpt-4o",
|
||||||
|
"meta/llama-3",
|
||||||
|
]);
|
||||||
|
expect(models[0]).toMatchObject({ label: "Claude Sonnet 5", toolCapable: true });
|
||||||
|
expect(models[2]).toMatchObject({ label: "Llama 3", toolCapable: false });
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
const calledUrl = String(fetchImpl.mock.calls[0][0]);
|
||||||
|
expect(calledUrl).toContain("supported_parameters=tools");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns cached results on subsequent calls within TTL", async () => {
|
||||||
|
const prisma = mockPrismaWithConnection();
|
||||||
|
const fetchImpl = mockFetchResponse([
|
||||||
|
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||||
|
]);
|
||||||
|
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||||
|
|
||||||
|
await catalog.listModels("org-1");
|
||||||
|
await catalog.listModels("org-1");
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-fetches after TTL expires", async () => {
|
||||||
|
const prisma = mockPrismaWithConnection();
|
||||||
|
const fetchImpl = mockFetchResponse([
|
||||||
|
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||||
|
]);
|
||||||
|
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||||
|
|
||||||
|
await catalog.listModels("org-1");
|
||||||
|
vi.advanceTimersByTime(61_000);
|
||||||
|
await catalog.listModels("org-1");
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty list when no ACTIVE provider connection exists", async () => {
|
||||||
|
const prisma = {
|
||||||
|
organizationProviderConnection: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const fetchImpl = mockFetchResponse([]);
|
||||||
|
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||||
|
|
||||||
|
const models = await catalog.listModels("org-1");
|
||||||
|
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidate forces a re-fetch on next call", async () => {
|
||||||
|
const prisma = mockPrismaWithConnection();
|
||||||
|
const fetchImpl = mockFetchResponse([
|
||||||
|
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||||
|
]);
|
||||||
|
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||||
|
|
||||||
|
await catalog.listModels("org-1");
|
||||||
|
catalog.invalidate("org-1");
|
||||||
|
await catalog.listModels("org-1");
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends auth token and anthropic API key in headers", async () => {
|
||||||
|
const prisma = mockPrismaWithConnection();
|
||||||
|
const fetchImpl = mockFetchResponse([
|
||||||
|
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
|
||||||
|
]);
|
||||||
|
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||||
|
|
||||||
|
await catalog.listModels("org-1");
|
||||||
|
|
||||||
|
const init = fetchImpl.mock.calls[0][1] as RequestInit;
|
||||||
|
const headers = init.headers as Record<string, string>;
|
||||||
|
expect(headers.authorization).toBe("Bearer sk-test-key");
|
||||||
|
expect(headers["x-api-key"]).toBe("sk-ant-test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-200 response", async () => {
|
||||||
|
const prisma = mockPrismaWithConnection();
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 401 });
|
||||||
|
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
|
||||||
|
|
||||||
|
await expect(catalog.listModels("org-1")).rejects.toThrow("provider models request failed: status 401");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,12 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promis
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { importSkillDirectory, prepareRunSkillPlugin } from "../../src/agent/skillStore.js";
|
import {
|
||||||
|
importSkillDirectory,
|
||||||
|
importSkillFromFiles,
|
||||||
|
prepareRunSkillPlugin,
|
||||||
|
readSkillFiles,
|
||||||
|
} from "../../src/agent/skillStore.js";
|
||||||
|
|
||||||
describe("content-addressed Agent skill store", () => {
|
describe("content-addressed Agent skill store", () => {
|
||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
@@ -55,6 +60,47 @@ describe("content-addressed Agent skill store", () => {
|
|||||||
})).rejects.toThrow(/content digest mismatch/);
|
})).rejects.toThrow(/content digest mismatch/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("imports a skill from an in-memory file list and reads it back", async () => {
|
||||||
|
const root = await makeRoot();
|
||||||
|
const storeRoot = join(root, "store");
|
||||||
|
const manifest = `---\nname: web-skill\ndescription: Web uploaded\n---\n# web-skill\n`;
|
||||||
|
const files = [
|
||||||
|
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||||
|
{ path: "reference.md", bytes: Buffer.from("ref\n", "utf8") },
|
||||||
|
];
|
||||||
|
const installed = await importSkillFromFiles({ files, storeRoot });
|
||||||
|
expect(installed).toMatchObject({ name: "web-skill", description: "Web uploaded" });
|
||||||
|
expect(installed.contentDigest).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
|
||||||
|
const readBack = await readSkillFiles({ storeRoot, contentDigest: installed.contentDigest });
|
||||||
|
expect(readBack).toHaveLength(2);
|
||||||
|
const byPath = Object.fromEntries(readBack.map((f) => [f.path, f.content]));
|
||||||
|
expect(byPath["SKILL.md"]).toContain("name: web-skill");
|
||||||
|
expect(byPath["reference.md"]).toBe("ref\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects file paths that escape the skill root", async () => {
|
||||||
|
const root = await makeRoot();
|
||||||
|
const storeRoot = join(root, "store");
|
||||||
|
const manifest = `---\nname: escape\ndescription: test\n---\n# escape\n`;
|
||||||
|
const files = [
|
||||||
|
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||||
|
{ path: "../escape.md", bytes: Buffer.from("x", "utf8") },
|
||||||
|
];
|
||||||
|
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/must not escape/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate file paths in importSkillFromFiles", async () => {
|
||||||
|
const root = await makeRoot();
|
||||||
|
const storeRoot = join(root, "store");
|
||||||
|
const manifest = `---\nname: dup\ndescription: test\n---\n# dup\n`;
|
||||||
|
const files = [
|
||||||
|
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||||
|
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||||
|
];
|
||||||
|
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/duplicate skill file path/);
|
||||||
|
});
|
||||||
|
|
||||||
async function makeRoot(): Promise<string> {
|
async function makeRoot(): Promise<string> {
|
||||||
const root = await mkdtemp(join(tmpdir(), "cph-skill-store-"));
|
const root = await mkdtemp(join(tmpdir(), "cph-skill-store-"));
|
||||||
roots.push(root);
|
roots.push(root);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Spec.System.Agent.Run
|
|||||||
import Spec.System.Agent.AgentRole
|
import Spec.System.Agent.AgentRole
|
||||||
import Spec.System.Agent.Memory
|
import Spec.System.Agent.Memory
|
||||||
import Spec.System.Agent.AgentSurface
|
import Spec.System.Agent.AgentSurface
|
||||||
|
import Spec.System.Agent.Usage
|
||||||
import Spec.System.Lock
|
import Spec.System.Lock
|
||||||
import Spec.System.Permission
|
import Spec.System.Permission
|
||||||
import Spec.System.PermissionGrant
|
import Spec.System.PermissionGrant
|
||||||
@@ -39,11 +40,14 @@ likec4 已画出结构;这里补语义:
|
|||||||
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权。
|
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权。
|
||||||
- `AgentSurface` —— agent 执行面被 run 的工作区所界定(ADR-0018);与 Lock 正交——
|
- `AgentSurface` —— agent 执行面被 run 的工作区所界定(ADR-0018);与 Lock 正交——
|
||||||
Lock 限定并发,Surface 限定波及面。机制 OPEN。
|
Lock 限定并发,Surface 限定波及面。机制 OPEN。
|
||||||
|
- `Usage` —— Run 内 append-only 用量计量事实账本(ADR-0026);主模型 completion、
|
||||||
|
外部能力调用、代理网关旁路共用同一账本。fact 不持锁、不跨 run;`costUsd = none`
|
||||||
|
表示未知而非零(ADR-0022)。Run 上的 cost/token 标量是其 rollup cache。
|
||||||
- `Permission` —— read⊂edit⊂manage 角色体系、能力推导、单调性;force-release 在格外。
|
- `Permission` —— read⊂edit⊂manage 角色体系、能力推导、单调性;force-release 在格外。
|
||||||
- `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)
|
- `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)
|
||||||
(ADR-0004);组合规则 OPEN。
|
(ADR-0004);组合规则 OPEN。
|
||||||
- `Audit` —— customer Project/Run 审计从简(内容 OPEN);Platform Audit 由
|
- `Audit` —— customer Project/Run 审计从简(内容 OPEN);Platform Audit 由
|
||||||
`PlatformAdministration` 独立承载。
|
`PlatformAdministration` 独立承载。
|
||||||
|
|
||||||
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0024。
|
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0026。
|
||||||
-/
|
-/
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import Spec.Prelude
|
||||||
|
|
||||||
|
/-!
|
||||||
|
# Usage —— Run 内用量计量事实账本(ADR-0026)
|
||||||
|
|
||||||
|
一次 `AgentRun` 内可能产生**多条**可计费消耗:主模型 completion、外部能力调用
|
||||||
|
(PDF→MD bundle、音视频转写等)、或代理网关侧旁路调用。这些消耗**不**是子 Run——
|
||||||
|
它们不持项目锁、不占 session 语义、不复用 `AgentRun` 状态机。它们是 Run 内的
|
||||||
|
append-only **计量事实**(`UsageFact`)。
|
||||||
|
|
||||||
|
`AgentRun` 上的 cost/token 标量是 facts 的派生 rollup cache,不是真相来源;真相在
|
||||||
|
`UsageFact` 行。这一层抽象让"主模型"与"外部能力"两类消耗共享同一账本,而非给后者
|
||||||
|
各开一种特化字段或 nested Run。
|
||||||
|
|
||||||
|
核心不变式(`PINNED`, ADR-0022 + ADR-0026):
|
||||||
|
|
||||||
|
- **append-only** —— fact 一旦写入只读不更不删;`costUsd` 修正走新 fact,不改旧行。
|
||||||
|
- **`costUsd = none` 表示"未知",不是"零成本"** —— ADR-0022:missing provider cost
|
||||||
|
remains unknown rather than zero。聚合时不得把 none 当 0 求和。
|
||||||
|
- **fact 不持锁、不跨 run** —— 它是 Run 内的副作用账本,不是又一次 `@bot` 生命周期。
|
||||||
|
外部能力调用亦同:它的语义边界是 `capabilityId` + `correlationId`,不是 `RunId`。
|
||||||
|
- **价目回算是派生** —— `costSource = pricebookDerived` 时,`costUsd` 由
|
||||||
|
`occurredAt × (provider, model)` 查价目表派生;provider 实报(`providerReported`)
|
||||||
|
优先于派生。无价目可查时 `costSource = unknown`,`costUsd = none`。
|
||||||
|
|
||||||
|
外部能力(capability)注册表、价目表(pricebook)、商业结算均 `OPEN`,pilot 不做
|
||||||
|
(ADR-0021 仍 defer commercial billing)。本模块只 pin **账本结构与不变式**。
|
||||||
|
-/
|
||||||
|
|
||||||
|
namespace Spec.System.Agent
|
||||||
|
|
||||||
|
variable (I : Identifiers) (Time Cost Quantity : Type)
|
||||||
|
|
||||||
|
/-- 计量事实类型(`PINNED`, ADR-0026)。集合完整性 `OPEN`——新增 kind 须 surface,
|
||||||
|
不得静默把外部消耗塞进既有 kind。 -/
|
||||||
|
inductive UsageFactKind where
|
||||||
|
/-- 主 agent loop 的模型 completion(`PINNED`)。 -/
|
||||||
|
| modelCompletion
|
||||||
|
/-- 外部能力调用(`PINNED`;如 pdf_to_md_bundle、audio_video_to_text)。 -/
|
||||||
|
| externalCapability
|
||||||
|
/-- 代理网关侧旁路调用(`PINNED`;非主 loop 的模型调用,如嵌入工具内的子请求)。 -/
|
||||||
|
| toolProxy
|
||||||
|
|
||||||
|
/-- 成本来源(`PINNED`, ADR-0026)。优先级:provider 实报 > 价目派生 > unknown。 -/
|
||||||
|
inductive CostSource where
|
||||||
|
/-- provider/gateway 实报(`PINNED`)。 -/
|
||||||
|
| providerReported
|
||||||
|
/-- 用 `occurredAt × (provider, model)` 价目表派生(`PINNED`)。 -/
|
||||||
|
| pricebookDerived
|
||||||
|
/-- 缺失,而非零(`PINNED`;ADR-0022 missing cost ≠ zero)。 -/
|
||||||
|
| unknown
|
||||||
|
|
||||||
|
/-- 一次可计费消耗的计量事实(`PINNED`, ADR-0026)。append-only;一次 run ≥0 条。
|
||||||
|
|
||||||
|
字段分两组:**归属与时间**(run/occurredAt/kind)用于聚合与价目回算;
|
||||||
|
**计量与成本**(tokens/quantity/unit/costUsd/costSource)用于账目本身。
|
||||||
|
非 token 计量(页/秒/次)走 `quantity + unit`,与 token 并存而非取代。 -/
|
||||||
|
structure UsageFact where
|
||||||
|
/-- 所属 run(`PINNED`;fact 不跨 run,fact 不持锁,ADR-0026)。 -/
|
||||||
|
run : I.RunId
|
||||||
|
/-- 消耗发生时刻(`PINNED`);价目回算依赖此字段而非 run.finishedAt,
|
||||||
|
因为外部能力可能早于 run 结束。 -/
|
||||||
|
occurredAt : Time
|
||||||
|
/-- 事实类型(`PINNED`, ADR-0026)。 -/
|
||||||
|
kind : UsageFactKind
|
||||||
|
/-- provider 标识(`PINNED`;如 openrouter、mineru、openai_whisper)。 -/
|
||||||
|
provider : String
|
||||||
|
/-- 模型标识(`OPEN`;非模型计费可为 none)。 -/
|
||||||
|
model : Option String
|
||||||
|
/-- 输入 tokens(`OPEN`;非 token 计量可为 none)。 -/
|
||||||
|
inputTokens : Option Nat
|
||||||
|
/-- 输出 tokens(`OPEN`)。 -/
|
||||||
|
outputTokens : Option Nat
|
||||||
|
/-- 非 token 计量数值(`OPEN`;如页数、音频秒数)。与 tokens 并存。 -/
|
||||||
|
quantity : Option Quantity
|
||||||
|
/-- 计量单位(`OPEN`;如 "pages"、"audio_seconds"、"invocations")。 -/
|
||||||
|
unit : Option String
|
||||||
|
/-- 成本(`PINNED`;none = 未知,非零,ADR-0022)。 -/
|
||||||
|
costUsd : Option Cost
|
||||||
|
/-- 成本来源(`PINNED`;costUsd ≠ none 时必填,costUsd = none 时为 `unknown`)。 -/
|
||||||
|
costSource : CostSource
|
||||||
|
/-- 外部能力标识(`OPEN`;kind = externalCapability 时填,如 pdf_to_md_bundle)。 -/
|
||||||
|
capabilityId : Option String
|
||||||
|
/-- 外部请求关联 id(`OPEN`;对账/幂等用,不参与聚合)。 -/
|
||||||
|
correlationId : Option String
|
||||||
|
|
||||||
|
/-- Fact 的成本是否**已知**(`PINNED`, ADR-0026)。聚合 rollup 时,只对已知成本求和;
|
||||||
|
未知成本的 fact 不贡献 0,而是让汇总保持未知(若所有 fact 均未知)或部分未知。 -/
|
||||||
|
def UsageFact.HasKnownCost (fact : UsageFact I Time Cost Quantity) : Prop :=
|
||||||
|
fact.costUsd ≠ none
|
||||||
|
|
||||||
|
end Spec.System.Agent
|
||||||
Reference in New Issue
Block a user