@meyicloud/meyi-cost-server 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -3,7 +3,7 @@
3
3
  ## Scope
4
4
 
5
5
  This package is a tenant-aware Express plugin for AWS cost overview, reports,
6
- budgets, alert dismissals, Cost Explorer, and optional CUR/Athena data. It is
6
+ budgets, alert dismissals, and mandatory CUR/Athena data. It is
7
7
  designed to be mounted inside a host backend; it does not own login, tenant
8
8
  onboarding, or the host plugin registry.
9
9
 
@@ -20,7 +20,7 @@ insight-cost-server/
20
20
  | |-- routes/ # Express paths and common error handling
21
21
  | |-- models/ # Immutable customer, SaaS CUR, and status models
22
22
  | |-- repositories/ # Tenant-scoped onboarding persistence reads
23
- | |-- services/ # Cost Explorer, SaaS Athena, context, budgets
23
+ | |-- services/ # CUR/Athena, external analyser, context, budgets
24
24
  | |-- schema/ # Plugin-owned database tables and installation
25
25
  | |-- lib/ # Stateless date, cost, and filter helpers
26
26
  | `-- plugin.js # Dependency composition and lifecycle
@@ -35,23 +35,13 @@ Keep HTTP concerns in routes/controllers, AWS and database behavior in services,
35
35
  schema creation in `schema`, and pure calculations in `lib`. `index.js` and
36
36
  `cur.js` must remain thin compatibility entry points.
37
37
 
38
- ## AI provider abstraction
38
+ ## External cost analyser
39
39
 
40
- `src/lib/llm-provider.js` is the only place that knows which vendor answers.
41
- `createLlmProvider({ provider, modelId, apiKey, region, ... })` returns an
42
- object with a single `converse({ system, messages, maxTokens, temperature,
43
- topP })` method resolving to `{ text }`. Bedrock, Anthropic and OpenAI are
44
- supported; `normalizeProviderName` matches loosely so UI labels like
45
- "AWS Bedrock" resolve correctly.
46
-
47
- `CostAnalysisService.resolveProvider(tenantId)` picks between a host-supplied
48
- `providerResolver` and the `COST_AI_*` environment defaults. Two rules matter:
49
- the analysis fingerprint is computed from the **effective** model so switching
50
- provider cannot serve a cached answer from the previous one, and a resolver
51
- failure degrades to the environment rather than failing the request.
52
-
53
- When adding a provider, add it here and to `test/llm-provider.test.js`; no
54
- other file should need to change.
40
+ The backend package never calls an LLM directly. `CostAnalysisReportWorker`
41
+ claims due tenant schedules and delegates execution to `CostAnalyserService`,
42
+ which starts one tenant-scoped `meyi-cost-ai-analyser` Fargate task. The task
43
+ writes canonical Markdown and derived PDF artifacts to private S3. Keep ECS
44
+ task overrides tenant-scoped and never log the customer role external ID.
55
45
 
56
46
  ## Request and data flow
57
47
 
@@ -62,21 +52,17 @@ flowchart LR
62
52
  Enabled --> Router[/api/v1/cost router]
63
53
  Router --> Controller[Cost controller]
64
54
  Controller --> Customer[Customer AWS context service]
65
- Customer -->|Customer role| CE[AWS Cost Explorer]
66
- Controller --> Source{Configured source}
67
- Source -->|CUR| SaaS[SaaS Athena context service]
55
+ Controller --> SaaS[SaaS Athena context service]
68
56
  SaaS --> Athena[Central tenant CUR partition]
69
- Source -->|auto fallback| CE
70
- Controller --> Budget[Budget and dismissal services]
71
- Budget --> DB[(Tenant-scoped PostgreSQL)]
72
- Athena --> Controller
73
- CE --> Controller
57
+ Athena --> Scheduler[Scheduled report worker]
58
+ Scheduler --> ECS[Cost analyser Fargate task]
59
+ ECS --> Artifacts[Private Markdown and PDF S3 objects]
74
60
  ```
75
61
 
76
62
  The plugin currently owns these route groups under `${apiBaseUri}/cost`:
77
63
 
78
64
  - `GET /accounts`, `/data-status`, `/overview`, `/filter-options`, `/reports`, `/tags`
79
- - `GET`/`PUT /analysis/schedule`, `GET /analysis/reports`, and tenant-scoped PDF downloads
65
+ - `GET`/`PUT /analysis/schedule`, `GET /analysis/reports`, and tenant-scoped Markdown/PDF downloads
80
66
  - `GET /budgets` and `POST /budgets`
81
67
  - `DELETE /budgets/:id`
82
68
  - `GET /budget-alert-dismissals` and `POST /budget-alert-dismissals`
package/README.md CHANGED
@@ -32,7 +32,7 @@ and AWS onboarding. The plugin owns cost routes and its budget-related tables.
32
32
  - User-, tenant-, month-, and status-scoped budget-alert dismissals
33
33
  - Tenant-configurable daily, weekly, and monthly AI report schedules
34
34
  - PostgreSQL-backed background report jobs with failure history
35
- - Server-generated PDF reports available through tenant-scoped downloads
35
+ - Analyser-generated Markdown and PDF reports available through tenant-scoped downloads
36
36
 
37
37
  Budgets are application rules stored in PostgreSQL; they are not AWS Budgets
38
38
  resources. The consuming application evaluates them against current cost and
@@ -97,7 +97,7 @@ For a local package installation test:
97
97
 
98
98
  ```bash
99
99
  npm pack
100
- npm install /path/to/meyicloud-meyi-cost-server-1.5.0.tgz
100
+ npm install /path/to/meyicloud-meyi-cost-server-1.6.0.tgz
101
101
  ```
102
102
 
103
103
  ## Publish to npm
@@ -258,12 +258,12 @@ SaaS Athena resources.
258
258
 
259
259
  ### AI analysis variables
260
260
 
261
- AI analysis is optional and invokes Amazon Bedrock from the backend runtime.
262
- Only aggregated cost facts are sent to the model; tenant IDs, AWS credentials,
263
- resource IDs, and raw CUR rows are excluded. AI reports are generated by a
264
- background worker, not by a browser request. Daily reports cover the previous
265
- complete day, weekly reports cover the previous seven complete days, and
266
- monthly reports cover the previous calendar month in the configured timezone.
261
+ AI reports are generated by a separate `meyi-cost-ai-analyser` Fargate task.
262
+ The backend only schedules jobs, launches and monitors the task, and serves
263
+ tenant-scoped artifacts. It does not invoke Bedrock or generate PDFs itself.
264
+ Daily reports cover the previous complete day, weekly reports cover the
265
+ previous seven complete days, and monthly reports cover the previous calendar
266
+ month in the configured timezone.
267
267
 
268
268
  The authenticated report API is:
269
269
 
@@ -274,6 +274,7 @@ The authenticated report API is:
274
274
  | `GET` | `/analysis/reports` | List tenant report history without PDF bytes. |
275
275
  | `GET` | `/analysis/reports/:id` | Read one tenant report. |
276
276
  | `GET` | `/analysis/reports/:id/pdf` | Download a completed report PDF. |
277
+ | `GET` | `/analysis/reports/:id/markdown` | Download the canonical Markdown report. |
277
278
 
278
279
  Schedule and report rows live in `cost_ai_report_schedules` and
279
280
  `cost_ai_reports`. The worker atomically claims due schedules with a lease, so
@@ -281,56 +282,29 @@ a restarted task can resume future work without relying on in-memory timers.
281
282
 
282
283
  | Variable | Default | Required | Purpose |
283
284
  | --- | --- | --- | --- |
284
- | `COST_AI_ENABLED` | `false` | No | Enables the AI analysis endpoints and Bedrock client. |
285
- | `COST_AI_REGION` | `AWS_REGION` or `us-east-1` | When enabled | Region used by the Bedrock Runtime client. |
286
- | `COST_AI_MODEL_ID` | Global Claude Sonnet inference profile | When enabled | Bedrock model or inference-profile ID. |
287
- | `COST_AI_MAX_TOKENS` | `4000` | No | Maximum response tokens, clamped from 600 to 8000. |
288
- | `COST_AI_CACHE_TTL_MS` | `21600000` | No | Tenant analysis cache duration; defaults to six hours. |
289
- | `COST_AI_HOURLY_LIMIT` | `6` | No | Maximum non-cached generations per tenant per hour. |
290
- | `COST_AI_TIMEOUT_MS` | `120000` | No | Per-request timeout for key-based providers; Bedrock uses the SDK default. |
285
+ | `COST_AI_ENABLED` | `false` | No | Enables the schedule worker and external analyser integration. |
286
+ | `COST_AI_ANALYSER_REGION` | `AWS_REGION` | When enabled | ECS and report-bucket region. |
287
+ | `COST_AI_ANALYSER_CLUSTER` | Empty | When enabled | ECS cluster ARN or name. |
288
+ | `COST_AI_ANALYSER_TASK_DEFINITION` | Empty | When enabled | Fargate task definition ARN. |
289
+ | `COST_AI_ANALYSER_CONTAINER_NAME` | `cost-ai-analyser` | No | Container override target. |
290
+ | `COST_AI_ANALYSER_SUBNETS` | Empty | When enabled | Comma-separated private subnet IDs. |
291
+ | `COST_AI_ANALYSER_SECURITY_GROUPS` | Empty | When enabled | Comma-separated task security groups. |
292
+ | `COST_AI_ANALYSER_REPORT_BUCKET` | Empty | When enabled | Private S3 Markdown/PDF artifact bucket. |
293
+ | `COST_AI_ANALYSER_PROVIDER_SECRET_PREFIX` | Empty | When enabled | Prefix for temporary Secrets Manager entries containing each tenant's active Meyi Connect AI provider configuration. |
294
+ | `COST_AI_ANALYSER_TARGET_REGIONS` | `AWS_REGION` | No | Customer regions inspected for resource detail. |
295
+ | `COST_AI_ANALYSER_TOP_N_SERVICES` | `10` | No | Maximum high-cost service agents run per report. |
296
+ | `COST_AI_ANALYSER_POLL_INTERVAL_MS` | `15000` | No | ECS task status polling interval. |
297
+ | `COST_AI_ANALYSER_TIMEOUT_MS` | `2700000` | No | Maximum external task duration. |
291
298
  | `COST_AI_REPORT_POLL_INTERVAL_MS` | `60000` | No | How often the background worker claims due schedules; minimum 10 seconds. |
292
- | `COST_AI_REPORT_LEASE_MS` | `1800000` | No | Claim lease used to recover from an interrupted worker. |
299
+ | `COST_AI_REPORT_LEASE_MS` | `3600000` | No | Claim lease used to recover from an interrupted worker. |
293
300
  | `COST_AI_REPORT_BATCH_SIZE` | `5` | No | Maximum schedules claimed by one worker tick; clamped from 1 to 25. |
294
301
 
295
- ### Choosing a provider
296
-
297
- The `COST_AI_*` variables above configure the deployment default, which is
298
- Bedrock. A host application can additionally supply `providerResolver` to
299
- `createInsightCost` to select a provider per tenant:
300
-
301
- ```js
302
- createInsightCost({
303
- app, db,
304
- providerResolver: async (tenantId) => ({
305
- provider: "anthropic", // bedrock | anthropic | openai
306
- modelId: "claude-sonnet-4-5",
307
- apiKey: "...", // required for anthropic and openai
308
- region: "ap-south-2", // bedrock only
309
- accessKeyId: "...", // bedrock only; omit to use ambient credentials
310
- secretAccessKey: "...",
311
- }),
312
- })
313
- ```
314
-
315
- Returning `null` falls back to the environment configuration, so a host that
316
- does not pass a resolver behaves exactly as before. A resolver that throws is
317
- logged and also falls back, so a credential-store outage degrades the feature
318
- rather than failing the request.
319
-
320
- Why this exists: Bedrock authenticates with SigV4 and is subject to
321
- account-level Anthropic model access. The key-based providers are not, so a
322
- deployment blocked on Bedrock model access can still run analysis. It also
323
- lets a customer bring their own model account.
324
-
325
- `GET /analysis/status` reports the provider that would serve the caller's
326
- tenant, including a `source` of `tenant-configuration` or `environment`.
327
- **Host applications are responsible for encrypting stored credentials.**
328
-
329
- The runtime role needs `bedrock:InvokeModel` for the selected model or
330
- inference profile. Analysis results are stored in the tenant-scoped
331
- `cost_ai_analyses` table and scheduled PDF jobs are stored in
332
- `cost_ai_reports`. Model or permission failures mark only that scheduled report
333
- as failed and do not change CUR, standard reports, onboarding, or budgets.
302
+ `GET /analysis/status` reports `meyi-cost-ai-analyser` with an `ecs-task`
303
+ source. The analyser uses the shared ECS task role for Athena, S3, Bedrock, and
304
+ cross-account role assumption. New reports store task and private artifact
305
+ references in `cost_ai_reports`; existing database-backed PDF rows remain
306
+ downloadable for compatibility. Model or task failures mark only that report as
307
+ failed and do not change CUR, standard reports, or onboarding.
334
308
 
335
309
  ### CUR/Athena variables
336
310
 
package/index.js CHANGED
@@ -1,2 +1 @@
1
1
  export { createInsightCost, createInsightCost as default } from "./src/plugin.js";
2
- export { createLlmProvider, normalizeProviderName, PROVIDER_BEDROCK, PROVIDER_ANTHROPIC, PROVIDER_OPENAI } from "./src/lib/llm-provider.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meyicloud/meyi-cost-server",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Tenant-aware AWS CUR and Athena cost plugin server for MeyiConnect",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -19,15 +19,15 @@
19
19
  ],
20
20
  "dependencies": {
21
21
  "@aws-sdk/client-athena": "^3.850.0",
22
- "@aws-sdk/client-bedrock-runtime": "^3.850.0",
22
+ "@aws-sdk/client-ecs": "^3.850.0",
23
23
  "@aws-sdk/client-glue": "^3.850.0",
24
24
  "@aws-sdk/client-s3": "^3.850.0",
25
+ "@aws-sdk/client-secrets-manager": "^3.850.0",
25
26
  "@aws-sdk/client-sts": "^3.850.0",
26
27
  "@aws-sdk/credential-providers": "^3.850.0",
27
28
  "drizzle-orm": "^0.44.7",
28
29
  "express": "^4.21.1",
29
- "luxon": "^3.7.2",
30
- "pdfkit": "^0.17.2"
30
+ "luxon": "^3.7.2"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "pg": ">=8"
@@ -12,7 +12,7 @@ export class CostAnalysisController {
12
12
 
13
13
  async latest(req, res, next) {
14
14
  try {
15
- return res.json({ analysis: await this.service.latest(req) });
15
+ return res.json({ analysis: await this.reportService.latest(req) });
16
16
  } catch (error) { return next(error); }
17
17
  }
18
18
 
@@ -45,4 +45,14 @@ export class CostAnalysisController {
45
45
  return res.send(report.data);
46
46
  } catch (error) { return next(error); }
47
47
  }
48
+
49
+ async downloadMarkdown(req, res, next) {
50
+ try {
51
+ const report = await this.reportService.markdown(req, req.params.id);
52
+ res.setHeader("Content-Type", "text/markdown; charset=utf-8");
53
+ res.setHeader("Content-Disposition", `attachment; filename="${report.filename}"`);
54
+ res.setHeader("Content-Length", report.data.length);
55
+ return res.send(report.data);
56
+ } catch (error) { return next(error); }
57
+ }
48
58
  }
package/src/plugin.js CHANGED
@@ -11,13 +11,11 @@ import { BudgetController } from "./controllers/budget.controller.js";
11
11
  import { CostAnalysisController } from "./controllers/cost-analysis.controller.js";
12
12
  import { createCostRouter } from "./routes/index.js";
13
13
  import { installCostBudgetSchema } from "./schema/cost-budget.schema.js";
14
- import { installCostAnalysisSchema } from "./schema/cost-analysis.schema.js";
15
14
  import { installCostAnalysisReportSchema } from "./schema/cost-analysis-report.schema.js";
16
- import { CostAnalysisRepository } from "./repositories/cost-analysis.repository.js";
17
15
  import { CostAnalysisReportRepository } from "./repositories/cost-analysis-report.repository.js";
18
- import { CostAnalysisDataService } from "./services/cost-analysis-data.service.js";
19
- import { CostAnalysisService } from "./services/cost-analysis.service.js";
16
+ import { CostAnalyserService } from "./services/cost-analyser.service.js";
20
17
  import { CostAnalysisReportService } from "./services/cost-analysis-report.service.js";
18
+ import { CostReportArtifactService } from "./services/cost-report-artifact.service.js";
21
19
  import { CostAnalysisReportWorker } from "./workers/cost-analysis-report.worker.js";
22
20
  import { installCurDiscoverySchema } from "./cur-discovery/schema.js";
23
21
  import { CurDiscoveryRepository } from "./cur-discovery/cur-discovery.repository.js";
@@ -35,7 +33,6 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
35
33
  const contextService = new CustomerAwsContextService({ repository: onboardingRepository, defaultTenant });
36
34
  const budgetRepository = new BudgetRepository({ db, qSchema });
37
35
  const budgetAlertRepository = new BudgetAlertRepository({ db, qSchema });
38
- const costAnalysisRepository = new CostAnalysisRepository({ db, qSchema });
39
36
  const costAnalysisReportRepository = new CostAnalysisReportRepository({ db, qSchema });
40
37
  const budgetService = new BudgetService({ repository: budgetRepository });
41
38
  const budgetAlertService = new BudgetAlertService({ repository: budgetAlertRepository });
@@ -54,20 +51,17 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
54
51
  const discoveryWorker = new CurDiscoveryWorker({ repository: discoveryRepository, service: discoveryService, logger });
55
52
  const costController = new CostController({ contextService, curProvider, discoveryRepository, logger });
56
53
  const budgetController = new BudgetController({ contextService, budgetService, budgetAlertService });
57
- const costAnalysisDataService = new CostAnalysisDataService({ curProvider });
58
- // providerResolver lets the host supply per-tenant LLM credentials (the AI
59
- // Providers screen). Null keeps the environment-only behaviour.
60
- const costAnalysisService = new CostAnalysisService({ contextService, dataService: costAnalysisDataService, repository: costAnalysisRepository, logger, providerResolver });
61
- const costAnalysisReportService = new CostAnalysisReportService({ contextService, repository: costAnalysisReportRepository, analysisService: costAnalysisService });
62
- const costAnalysisReportWorker = new CostAnalysisReportWorker({ repository: costAnalysisReportRepository, analysisService: costAnalysisService, logger });
63
- const costAnalysisController = new CostAnalysisController({ service: costAnalysisService, reportService: costAnalysisReportService });
54
+ const costAnalyserService = new CostAnalyserService({ contextService, athenaContextService, providerResolver, logger });
55
+ const artifactService = new CostReportArtifactService();
56
+ const costAnalysisReportService = new CostAnalysisReportService({ contextService, repository: costAnalysisReportRepository, artifactService });
57
+ const costAnalysisReportWorker = new CostAnalysisReportWorker({ repository: costAnalysisReportRepository, analyserService: costAnalyserService, logger });
58
+ const costAnalysisController = new CostAnalysisController({ service: costAnalyserService, reportService: costAnalysisReportService });
64
59
  const router = createCostRouter({ costController, budgetController, costAnalysisController }, logger);
65
60
  let mounted = false;
66
61
 
67
62
  return {
68
63
  async install() {
69
64
  await installCostBudgetSchema(db, qSchema);
70
- await installCostAnalysisSchema(db, qSchema);
71
65
  await installCostAnalysisReportSchema(db, qSchema);
72
66
  await installCurDiscoverySchema(db, qSchema);
73
67
  logger.log?.("[Cost] Database migration verified");
@@ -24,9 +24,10 @@ export class AwsOnboardingRepository {
24
24
 
25
25
  const connection = rows(await this.db.execute(sql`
26
26
  SELECT
27
- connection_id,
28
- role_arn,
29
- external_id
27
+ connection_id,
28
+ role_arn,
29
+ external_id,
30
+ management_account_id
30
31
  FROM ${sql.raw(this.connectionsTable)}
31
32
  WHERE tenant_id::text = ${tenant}
32
33
  AND status = 'CONNECTED'
@@ -72,7 +73,8 @@ export class AwsOnboardingRepository {
72
73
  status: item.status,
73
74
  })),
74
75
  meta: {
75
- payerRoleArn: connection.role_arn,
76
+ payerRoleArn: connection.role_arn,
77
+ managementAccountId: connection.management_account_id,
76
78
  externalId: connection.external_id,
77
79
  connectionId: connection.connection_id,
78
80
  curExportArn: curConfig.export_arn,
@@ -71,6 +71,23 @@ export class CostAnalysisReportRepository {
71
71
  return rows(await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET status = 'RUNNING', attempt_count = attempt_count + 1, started_at = now(), error = NULL, updated_at = now() WHERE id = ${id} AND status = 'PENDING' RETURNING *`))[0] || null;
72
72
  }
73
73
 
74
+ async setTaskArn(id, taskArn) {
75
+ await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET ecs_task_arn = ${taskArn}, updated_at = now() WHERE id = ${id}`);
76
+ }
77
+
78
+ async completeExternal(id, result) {
79
+ await this.db.execute(sql`
80
+ UPDATE ${sql.raw(this.reports)} SET status = 'COMPLETED', ecs_task_arn = ${result.taskArn},
81
+ model_id = ${result.modelId}, provider_name = ${result.provider}, provider_source = ${result.source},
82
+ data_source = ${result.dataSource}, facts = ${JSON.stringify(result.facts || {})}::jsonb,
83
+ result = ${JSON.stringify(result.summary || {})}::jsonb, artifact_bucket = ${result.artifactBucket},
84
+ markdown_key = ${result.markdownKey}, markdown_size = ${Number(result.markdownSize || 0)},
85
+ pdf_key = ${result.pdfKey}, pdf_size = ${Number(result.pdfSize || 0)}, pdf_data = NULL,
86
+ completed_at = now(), updated_at = now()
87
+ WHERE id = ${id}
88
+ `);
89
+ }
90
+
74
91
  async complete(id, analysis, pdf) {
75
92
  await this.db.execute(sql`
76
93
  UPDATE ${sql.raw(this.reports)} SET status = 'COMPLETED', model_id = ${analysis.modelId}, data_source = ${analysis.dataSource || null},
@@ -82,13 +99,14 @@ export class CostAnalysisReportRepository {
82
99
  }
83
100
 
84
101
  async fail(id, error) {
85
- await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET status = 'FAILED', error = ${String(error?.message || error).slice(0, 2000)}, completed_at = now(), updated_at = now() WHERE id = ${id}`);
102
+ await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET status = 'FAILED', ecs_task_arn = COALESCE(${error?.taskArn || null}, ecs_task_arn), error = ${String(error?.message || error).slice(0, 2000)}, completed_at = now(), updated_at = now() WHERE id = ${id}`);
86
103
  }
87
104
 
88
105
  async list(tenantId, limit = 100) {
89
106
  return rows(await this.db.execute(sql`
90
107
  SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
91
- attempt_count, model_id, data_source, facts, result, pdf_size, error,
108
+ attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
109
+ markdown_key, markdown_size, pdf_key, pdf_size, error,
92
110
  started_at, completed_at, created_at
93
111
  FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId}
94
112
  ORDER BY scheduled_for DESC LIMIT ${Math.min(Math.max(Number(limit) || 100, 1), 250)}
@@ -101,7 +119,8 @@ export class CostAnalysisReportRepository {
101
119
  }
102
120
  return rows(await this.db.execute(sql`
103
121
  SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
104
- attempt_count, model_id, data_source, facts, result, pdf_size, error,
122
+ attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
123
+ markdown_key, markdown_size, pdf_key, pdf_size, error,
105
124
  started_at, completed_at, created_at
106
125
  FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND id = ${id} LIMIT 1
107
126
  `))[0] || null;
@@ -16,6 +16,7 @@ export function createCostRouter({ costController, budgetController, costAnalysi
16
16
  router.get("/analysis/reports", costAnalysisController.reports.bind(costAnalysisController));
17
17
  router.get("/analysis/reports/:id", costAnalysisController.report.bind(costAnalysisController));
18
18
  router.get("/analysis/reports/:id/pdf", costAnalysisController.downloadPdf.bind(costAnalysisController));
19
+ router.get("/analysis/reports/:id/markdown", costAnalysisController.downloadMarkdown.bind(costAnalysisController));
19
20
  router.get("/budgets", budgetController.list.bind(budgetController));
20
21
  router.post("/budgets", budgetController.create.bind(budgetController));
21
22
  router.delete("/budgets/:id", budgetController.delete.bind(budgetController));
@@ -26,11 +26,18 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
26
26
  status text NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED')),
27
27
  attempt_count integer NOT NULL DEFAULT 0,
28
28
  model_id text,
29
+ provider_name text,
30
+ provider_source text,
29
31
  data_source text,
30
32
  facts jsonb,
31
33
  result jsonb,
32
34
  pdf_data bytea,
33
35
  pdf_size integer,
36
+ ecs_task_arn text,
37
+ artifact_bucket text,
38
+ markdown_key text,
39
+ markdown_size integer,
40
+ pdf_key text,
34
41
  error text,
35
42
  started_at timestamptz,
36
43
  completed_at timestamptz,
@@ -40,4 +47,11 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
40
47
  )`));
41
48
  await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_reports_tenant_created_idx ON ${qSchema}.cost_ai_reports (tenant_id, created_at DESC)`));
42
49
  await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_reports_status_idx ON ${qSchema}.cost_ai_reports (status, created_at)`));
50
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS ecs_task_arn text`));
51
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS artifact_bucket text`));
52
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS markdown_key text`));
53
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS markdown_size integer`));
54
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS pdf_key text`));
55
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS provider_name text`));
56
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS provider_source text`));
43
57
  }