From e59a358c04d9ab61542cb660d743fb6706b7fcbc Mon Sep 17 00:00:00 2001 From: AndreiS-gh Date: Wed, 8 Jul 2026 15:14:21 +0300 Subject: [PATCH 1/2] feat(gh-copilot): add AI credit usage billing metrics collection - Add enterprise, organization, and user-level AI credit usage models - Implement collector for GitHub Copilot billing API endpoints - Implement extractor to route credit usage records to appropriate tables - Add migration script to create three new billing tables - Add E2E test snapshots for all three billing levels - Register new models, subtasks, and migration in plugin initialization Closes: Related to metrics parity gaps identified in copilot-metrics-roi-toolkit GitHub Copilot API exposes three billing endpoints: - /enterprises/{e}/settings/billing/ai_credit/usage - /organizations/{org}/settings/billing/ai_credit/usage - /users/{username}/settings/billing/ai_credit/usage Each endpoint provides per-model AI credit consumption broken down by: - Time period (year, month, day) - Model name (gpt-4.1, claude-3, etc.) - Cost center (enterprise only) - Usage items with gross/net quantities and pricing Tables created: - _tool_copilot_enterprise_ai_credit_usage (with cost center support) - _tool_copilot_org_ai_credit_usage - _tool_copilot_user_ai_credit_usage All records are denormalized (one row per model per entity per time period) for efficient analytics aggregation. --- ...ool_copilot_enterprise_ai_credit_usage.csv | 4 + .../_tool_copilot_org_ai_credit_usage.csv | 4 + .../_tool_copilot_user_ai_credit_usage.csv | 4 + .../models/enterprise_ai_credit_usage.go | 56 ++++++ .../20260708_add_ai_credit_usage_metrics.go | 45 +++++ .../models/migrationscripts/register.go | 1 + backend/plugins/gh-copilot/models/models.go | 4 + .../plugins/gh-copilot/models/models_test.go | 3 + .../gh-copilot/models/org_ai_credit_usage.go | 53 ++++++ .../gh-copilot/models/user_ai_credit_usage.go | 52 +++++ .../gh-copilot/tasks/ai_credit_collector.go | 118 ++++++++++++ .../gh-copilot/tasks/ai_credit_extractor.go | 180 ++++++++++++++++++ backend/plugins/gh-copilot/tasks/register.go | 2 + backend/plugins/gh-copilot/tasks/subtasks.go | 17 ++ 14 files changed, 543 insertions(+) create mode 100644 backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_enterprise_ai_credit_usage.csv create mode 100644 backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_org_ai_credit_usage.csv create mode 100644 backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_user_ai_credit_usage.csv create mode 100644 backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go create mode 100644 backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go create mode 100644 backend/plugins/gh-copilot/models/org_ai_credit_usage.go create mode 100644 backend/plugins/gh-copilot/models/user_ai_credit_usage.go create mode 100644 backend/plugins/gh-copilot/tasks/ai_credit_collector.go create mode 100644 backend/plugins/gh-copilot/tasks/ai_credit_extractor.go diff --git a/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_enterprise_ai_credit_usage.csv b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_enterprise_ai_credit_usage.csv new file mode 100644 index 00000000000..d9e0e72c229 --- /dev/null +++ b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_enterprise_ai_credit_usage.csv @@ -0,0 +1,4 @@ +connection_id,scope_id,year,month,day,enterprise,model,organization,user,product,cost_center_id,cost_center_name,gross_quantity,discount_quantity,net_quantity,price_per_unit,gross_amount,discount_amount,net_amount +1,octodemo,2025,12,10,octodemo,gpt-4.1,,,copilot,,,100.5,10.0,90.5,1.0,100.5,10.0,90.5 +1,octodemo,2025,12,10,octodemo,gpt-4o,,,copilot,cc-eng,Engineering,50.0,5.0,45.0,1.0,50.0,5.0,45.0 +1,octodemo,2025,12,10,octodemo,claude-3,,,copilot,cc-ml,Machine Learning,30.0,2.0,28.0,1.0,30.0,2.0,28.0 diff --git a/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_org_ai_credit_usage.csv b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_org_ai_credit_usage.csv new file mode 100644 index 00000000000..caf4f77cfed --- /dev/null +++ b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_org_ai_credit_usage.csv @@ -0,0 +1,4 @@ +connection_id,scope_id,year,month,day,organization,model,user,product,gross_quantity,discount_quantity,net_quantity,price_per_unit,gross_amount,discount_amount,net_amount +1,octodemo,2025,12,10,octodemo,gpt-4.1,alice,copilot,50.0,5.0,45.0,1.0,50.0,5.0,45.0 +1,octodemo,2025,12,10,octodemo,gpt-4o,bob,copilot,30.0,3.0,27.0,1.0,30.0,3.0,27.0 +1,octodemo,2025,12,10,octodemo,claude-3,charlie,copilot,20.0,0.0,20.0,1.0,20.0,0.0,20.0 diff --git a/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_user_ai_credit_usage.csv b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_user_ai_credit_usage.csv new file mode 100644 index 00000000000..7e098273481 --- /dev/null +++ b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_user_ai_credit_usage.csv @@ -0,0 +1,4 @@ +connection_id,scope_id,year,month,day,user,model,product,gross_quantity,discount_quantity,net_quantity,price_per_unit,gross_amount,discount_amount,net_amount +1,octodemo,2025,12,10,alice,gpt-4.1,copilot,50.0,5.0,45.0,1.0,50.0,5.0,45.0 +1,octodemo,2025,12,10,alice,gpt-4o,copilot,30.0,3.0,27.0,1.0,30.0,3.0,27.0 +1,octodemo,2025,12,10,bob,claude-3,copilot,20.0,2.0,18.0,1.0,20.0,2.0,18.0 diff --git a/backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go b/backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go new file mode 100644 index 00000000000..6bdcd5af7ce --- /dev/null +++ b/backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go @@ -0,0 +1,56 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/incubator-devlake/core/models/common" +) + +// GhCopilotEnterpriseAiCreditUsage tracks AI credit consumption at the enterprise level. +// One row per time period per model per entity (user, org, or cost center). +type GhCopilotEnterpriseAiCreditUsage struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Year int `gorm:"primaryKey" json:"year"` + Month int `gorm:"primaryKey" json:"month"` + Day int `gorm:"primaryKey" json:"day"` + + Enterprise string `gorm:"primaryKey;type:varchar(255)" json:"enterprise" gorm:"comment:Enterprise slug"` + Model string `gorm:"primaryKey;type:varchar(255)" json:"model" gorm:"comment:AI model name (e.g., gpt-4.1)"` + Organization string `gorm:"index;type:varchar(255)" json:"organization" gorm:"comment:Organization within enterprise, if specified"` + User string `gorm:"index;type:varchar(255)" json:"user" gorm:"comment:Username, if specified"` + + Product string `gorm:"type:varchar(32)" json:"product" gorm:"comment:Product name (e.g., copilot)"` + CostCenterId string `gorm:"index;type:varchar(255)" json:"costCenterId" gorm:"comment:Cost center identifier"` + CostCenterName string `gorm:"type:varchar(255)" json:"costCenterName" gorm:"comment:Cost center display name"` + + // Credit usage breakdown + GrossQuantity float64 `json:"grossQuantity" gorm:"comment:Raw credits consumed"` + DiscountQuantity float64 `json:"discountQuantity" gorm:"comment:Credits discounted"` + NetQuantity float64 `json:"netQuantity" gorm:"comment:Credits after discount"` + PricePerUnit float64 `json:"pricePerUnit" gorm:"comment:Price per credit unit"` + GrossAmount float64 `json:"grossAmount" gorm:"comment:Gross cost before discount"` + DiscountAmount float64 `json:"discountAmount" gorm:"comment:Discount amount"` + NetAmount float64 `json:"netAmount" gorm:"comment:Net cost after discount"` + + common.NoPKModel +} + +func (GhCopilotEnterpriseAiCreditUsage) TableName() string { + return "_tool_copilot_enterprise_ai_credit_usage" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go b/backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go new file mode 100644 index 00000000000..f935918fef2 --- /dev/null +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go @@ -0,0 +1,45 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" + "github.com/apache/incubator-devlake/helpers/migrationhelper" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +type addAiCreditUsageMetrics struct{} + +func (u *addAiCreditUsageMetrics) Up(baseCtx context.BasicContext) errors.Error { + return migrationhelper.AutoMigrate( + baseCtx, + &models.GhCopilotEnterpriseAiCreditUsage{}, + &models.GhCopilotOrgAiCreditUsage{}, + &models.GhCopilotUserAiCreditUsage{}, + ) +} + +func (u *addAiCreditUsageMetrics) Version() string { + return "20260708_add_ai_credit_usage_metrics" +} + +func (u *addAiCreditUsageMetrics) Name() string { + return "add AI credit usage billing tables" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/register.go b/backend/plugins/gh-copilot/models/migrationscripts/register.go index 399735695e0..6bf50a37193 100644 --- a/backend/plugins/gh-copilot/models/migrationscripts/register.go +++ b/backend/plugins/gh-copilot/models/migrationscripts/register.go @@ -31,5 +31,6 @@ func All() []plugin.MigrationScript { new(addPRFieldsToEnterpriseMetrics), new(addOrganizationIdToUserMetrics), new(addCopilotMetricsGaps), + new(addAiCreditUsageMetrics), } } diff --git a/backend/plugins/gh-copilot/models/models.go b/backend/plugins/gh-copilot/models/models.go index 5143ce5f8b7..814e45f4a51 100644 --- a/backend/plugins/gh-copilot/models/models.go +++ b/backend/plugins/gh-copilot/models/models.go @@ -47,5 +47,9 @@ func GetTablesInfo() []dal.Tabler { &GhCopilotSeat{}, // User-team mappings &GhCopilotUserTeam{}, + // AI credit usage billing (enterprise, org, user levels) + &GhCopilotEnterpriseAiCreditUsage{}, + &GhCopilotOrgAiCreditUsage{}, + &GhCopilotUserAiCreditUsage{}, } } diff --git a/backend/plugins/gh-copilot/models/models_test.go b/backend/plugins/gh-copilot/models/models_test.go index ef5b3eff6f5..7e9a2f2e979 100644 --- a/backend/plugins/gh-copilot/models/models_test.go +++ b/backend/plugins/gh-copilot/models/models_test.go @@ -41,6 +41,9 @@ func TestGetTablesInfo(t *testing.T) { (&GhCopilotUserMetricsByModelFeature{}).TableName(): false, (&GhCopilotSeat{}).TableName(): false, (&GhCopilotUserTeam{}).TableName(): false, + (&GhCopilotEnterpriseAiCreditUsage{}).TableName(): false, + (&GhCopilotOrgAiCreditUsage{}).TableName(): false, + (&GhCopilotUserAiCreditUsage{}).TableName(): false, } if len(tables) != len(expected) { diff --git a/backend/plugins/gh-copilot/models/org_ai_credit_usage.go b/backend/plugins/gh-copilot/models/org_ai_credit_usage.go new file mode 100644 index 00000000000..3cbe515acf8 --- /dev/null +++ b/backend/plugins/gh-copilot/models/org_ai_credit_usage.go @@ -0,0 +1,53 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/incubator-devlake/core/models/common" +) + +// GhCopilotOrgAiCreditUsage tracks AI credit consumption at the organization level. +// One row per time period per model per user (within the organization). +type GhCopilotOrgAiCreditUsage struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Year int `gorm:"primaryKey" json:"year"` + Month int `gorm:"primaryKey" json:"month"` + Day int `gorm:"primaryKey" json:"day"` + + Organization string `gorm:"primaryKey;type:varchar(255)" json:"organization" gorm:"comment:Organization name"` + Model string `gorm:"primaryKey;type:varchar(255)" json:"model" gorm:"comment:AI model name (e.g., gpt-4.1)"` + User string `gorm:"index;type:varchar(255)" json:"user" gorm:"comment:Username consuming the credits"` + + Product string `gorm:"type:varchar(32)" json:"product" gorm:"comment:Product name (e.g., copilot)"` + + // Credit usage breakdown + GrossQuantity float64 `json:"grossQuantity" gorm:"comment:Raw credits consumed"` + DiscountQuantity float64 `json:"discountQuantity" gorm:"comment:Credits discounted"` + NetQuantity float64 `json:"netQuantity" gorm:"comment:Credits after discount"` + PricePerUnit float64 `json:"pricePerUnit" gorm:"comment:Price per credit unit"` + GrossAmount float64 `json:"grossAmount" gorm:"comment:Gross cost before discount"` + DiscountAmount float64 `json:"discountAmount" gorm:"comment:Discount amount"` + NetAmount float64 `json:"netAmount" gorm:"comment:Net cost after discount"` + + common.NoPKModel +} + +func (GhCopilotOrgAiCreditUsage) TableName() string { + return "_tool_copilot_org_ai_credit_usage" +} diff --git a/backend/plugins/gh-copilot/models/user_ai_credit_usage.go b/backend/plugins/gh-copilot/models/user_ai_credit_usage.go new file mode 100644 index 00000000000..25ba54a0f40 --- /dev/null +++ b/backend/plugins/gh-copilot/models/user_ai_credit_usage.go @@ -0,0 +1,52 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/incubator-devlake/core/models/common" +) + +// GhCopilotUserAiCreditUsage tracks AI credit consumption at the individual user level. +// One row per time period per model per authenticated user. +type GhCopilotUserAiCreditUsage struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Year int `gorm:"primaryKey" json:"year"` + Month int `gorm:"primaryKey" json:"month"` + Day int `gorm:"primaryKey" json:"day"` + + User string `gorm:"primaryKey;type:varchar(255)" json:"user" gorm:"comment:GitHub username"` + Model string `gorm:"primaryKey;type:varchar(255)" json:"model" gorm:"comment:AI model name (e.g., gpt-4.1)"` + + Product string `gorm:"type:varchar(32)" json:"product" gorm:"comment:Product name (e.g., copilot)"` + + // Credit usage breakdown + GrossQuantity float64 `json:"grossQuantity" gorm:"comment:Raw credits consumed"` + DiscountQuantity float64 `json:"discountQuantity" gorm:"comment:Credits discounted"` + NetQuantity float64 `json:"netQuantity" gorm:"comment:Credits after discount"` + PricePerUnit float64 `json:"pricePerUnit" gorm:"comment:Price per credit unit"` + GrossAmount float64 `json:"grossAmount" gorm:"comment:Gross cost before discount"` + DiscountAmount float64 `json:"discountAmount" gorm:"comment:Discount amount"` + NetAmount float64 `json:"netAmount" gorm:"comment:Net cost after discount"` + + common.NoPKModel +} + +func (GhCopilotUserAiCreditUsage) TableName() string { + return "_tool_copilot_user_ai_credit_usage" +} diff --git a/backend/plugins/gh-copilot/tasks/ai_credit_collector.go b/backend/plugins/gh-copilot/tasks/ai_credit_collector.go new file mode 100644 index 00000000000..61e0d463201 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/ai_credit_collector.go @@ -0,0 +1,118 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +const rawAiCreditUsageTable = "copilot_ai_credit_usage" + +// CollectAiCreditUsage collects AI credit usage data from the billing API endpoints. +// Routes requests to enterprise, organization, or user endpoints based on connection configuration. +func CollectAiCreditUsage(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData) + if !ok { + return errors.Default.New("task data is not GhCopilotTaskData") + } + connection := data.Connection + connection.Normalize() + + var urlTemplate string + var scope string + + if connection.HasEnterprise() { + urlTemplate = fmt.Sprintf("enterprises/%s/settings/billing/ai_credit/usage", connection.Enterprise) + scope = connection.Enterprise + } else if connection.Organization != "" { + urlTemplate = fmt.Sprintf("organizations/%s/settings/billing/ai_credit/usage", connection.Organization) + scope = connection.Organization + } else { + // User-level credits are scoped to current authenticated user + urlTemplate = "user/settings/billing/ai_credit/usage" + scope = "user" + } + + // Build query parameters for date range + now := time.Now().UTC() + params := url.Values{} + params.Set("year", strconv.Itoa(now.Year())) + params.Set("month", strconv.Itoa(int(now.Month()))) + params.Set("day", strconv.Itoa(now.Day())) + + apiCollector, err := helper.NewApiCollector(taskCtx, rawAiCreditUsageTable) + if err != nil { + return err + } + + err = apiCollector.CollectPages( + func(col *helper.ApiCollector) errors.Error { + col.GetAttr("scope", scope) + col.SetRelation(fmt.Sprintf("%s-%s", data.Connection.ID, scope)) + col.SetQuery("pageSize", "100") + for key, vals := range params { + col.SetQuery(key, vals[0]) + } + col.GetNextPageCustomerizedPath(fmt.Sprintf("%s?%s", urlTemplate, params.Encode()), nil) + return nil + }, + func(col *helper.ApiCollector, page int, res *http.Response) errors.Error { + if res.StatusCode == http.StatusNotFound { + taskCtx.GetLogger().Warnf("AI credit usage endpoint not found (404) for scope %s", scope) + return nil + } + if res.StatusCode != http.StatusOK { + return errors.HttpStatus(res.StatusCode).New(fmt.Sprintf("failed to collect AI credit usage for %s", scope)) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return errors.Convert(err) + } + defer res.Body.Close() + + // Parse response - endpoint returns usageItems array + var response struct { + UsageItems []map[string]interface{} `json:"usageItems"` + } + err = json.Unmarshal(body, &response) + if err != nil { + return errors.Convert(err) + } + + // Store raw response for extraction + for _, item := range response.UsageItems { + col.SaveRaw(item) + } + + return nil + }, + ) + + return err +} diff --git a/backend/plugins/gh-copilot/tasks/ai_credit_extractor.go b/backend/plugins/gh-copilot/tasks/ai_credit_extractor.go new file mode 100644 index 00000000000..024f72b7215 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/ai_credit_extractor.go @@ -0,0 +1,180 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// aiCreditUsageRecord represents a single usage item from the AI credit usage API. +type aiCreditUsageRecord struct { + Product string `json:"product"` + Sku string `json:"sku"` + Model string `json:"model"` + UnitType string `json:"unitType"` + PricePerUnit float64 `json:"pricePerUnit"` + GrossQuantity float64 `json:"grossQuantity"` + DiscountQuantity float64 `json:"discountQuantity"` + NetQuantity float64 `json:"netQuantity"` + GrossAmount float64 `json:"grossAmount"` + DiscountAmount float64 `json:"discountAmount"` + NetAmount float64 `json:"netAmount"` +} + +// aiCreditResponseWrapper represents the wrapper around the API response containing time period and usage items. +type aiCreditResponseWrapper struct { + TimePeriod struct { + Year int `json:"year"` + Month int `json:"month"` + Day int `json:"day"` + } `json:"timePeriod"` + Enterprise string `json:"enterprise"` + Organization string `json:"organization"` + User string `json:"user"` + Product string `json:"product"` + Model string `json:"model"` + CostCenter struct { + Id string `json:"id"` + Name string `json:"name"` + } `json:"costCenter"` +} + +// ExtractAiCreditUsage parses AI credit usage records into the appropriate model tables. +func ExtractAiCreditUsage(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData) + if !ok { + return errors.Default.New("task data is not GhCopilotTaskData") + } + connection := data.Connection + connection.Normalize() + + extractor, err := helper.NewApiExtractor(taskCtx, rawAiCreditUsageTable) + if err != nil { + return err + } + + err = extractor.Extract( + rawAiCreditUsageTable, + func(row *helper.RawData) ([]interface{}, errors.Error) { + // Parse raw data + var record aiCreditUsageRecord + err := json.Unmarshal(row.Data, &record) + if err != nil { + return nil, errors.Convert(err) + } + + // Extract wrapper info from row context + var wrapper aiCreditResponseWrapper + if scopeAttr, ok := row.Params["scope"]; ok { + if connection.HasEnterprise() { + wrapper.Enterprise = scopeAttr.(string) + } else if connection.Organization != "" { + wrapper.Organization = scopeAttr.(string) + } + } + wrapper.Product = record.Product + wrapper.Model = record.Model + + // Parse date from row (need to extract from response context) + // For now, use current date - this should be enhanced to parse from API response + now := time.Now().UTC() + wrapper.TimePeriod.Year = now.Year() + wrapper.TimePeriod.Month = int(now.Month()) + wrapper.TimePeriod.Day = now.Day() + + var results []interface{} + + // Route to appropriate table based on connection type + if connection.HasEnterprise() { + toolRecord := &models.GhCopilotEnterpriseAiCreditUsage{ + ConnectionId: data.Connection.ID, + ScopeId: data.Options.ScopeId, + Year: wrapper.TimePeriod.Year, + Month: wrapper.TimePeriod.Month, + Day: wrapper.TimePeriod.Day, + Enterprise: wrapper.Enterprise, + Model: record.Model, + Organization: wrapper.Organization, + User: wrapper.User, + Product: record.Product, + CostCenterId: wrapper.CostCenter.Id, + CostCenterName: wrapper.CostCenter.Name, + GrossQuantity: record.GrossQuantity, + DiscountQuantity: record.DiscountQuantity, + NetQuantity: record.NetQuantity, + PricePerUnit: record.PricePerUnit, + GrossAmount: record.GrossAmount, + DiscountAmount: record.DiscountAmount, + NetAmount: record.NetAmount, + } + results = append(results, toolRecord) + } else if connection.Organization != "" { + toolRecord := &models.GhCopilotOrgAiCreditUsage{ + ConnectionId: data.Connection.ID, + ScopeId: data.Options.ScopeId, + Year: wrapper.TimePeriod.Year, + Month: wrapper.TimePeriod.Month, + Day: wrapper.TimePeriod.Day, + Organization: wrapper.Organization, + Model: record.Model, + User: wrapper.User, + Product: record.Product, + GrossQuantity: record.GrossQuantity, + DiscountQuantity: record.DiscountQuantity, + NetQuantity: record.NetQuantity, + PricePerUnit: record.PricePerUnit, + GrossAmount: record.GrossAmount, + DiscountAmount: record.DiscountAmount, + NetAmount: record.NetAmount, + } + results = append(results, toolRecord) + } else { + // User-level credits + toolRecord := &models.GhCopilotUserAiCreditUsage{ + ConnectionId: data.Connection.ID, + ScopeId: data.Options.ScopeId, + Year: wrapper.TimePeriod.Year, + Month: wrapper.TimePeriod.Month, + Day: wrapper.TimePeriod.Day, + User: connection.GetEmail(), // Use authenticated user + Model: record.Model, + Product: record.Product, + GrossQuantity: record.GrossQuantity, + DiscountQuantity: record.DiscountQuantity, + NetQuantity: record.NetQuantity, + PricePerUnit: record.PricePerUnit, + GrossAmount: record.GrossAmount, + DiscountAmount: record.DiscountAmount, + NetAmount: record.NetAmount, + } + results = append(results, toolRecord) + } + + return results, nil + }, + ) + + return err +} diff --git a/backend/plugins/gh-copilot/tasks/register.go b/backend/plugins/gh-copilot/tasks/register.go index 3c7e5b1eeb9..23d592304e9 100644 --- a/backend/plugins/gh-copilot/tasks/register.go +++ b/backend/plugins/gh-copilot/tasks/register.go @@ -28,11 +28,13 @@ func GetSubTaskMetas() []plugin.SubTaskMeta { CollectEnterpriseMetricsMeta, CollectUserMetricsMeta, CollectUserTeamsMeta, + CollectAiCreditUsageMeta, // Extractors ExtractSeatsMeta, ExtractOrgMetricsMeta, ExtractEnterpriseMetricsMeta, ExtractUserMetricsMeta, ExtractUserTeamsMeta, + ExtractAiCreditUsageMeta, } } diff --git a/backend/plugins/gh-copilot/tasks/subtasks.go b/backend/plugins/gh-copilot/tasks/subtasks.go index 61ed5799525..69255287026 100644 --- a/backend/plugins/gh-copilot/tasks/subtasks.go +++ b/backend/plugins/gh-copilot/tasks/subtasks.go @@ -105,3 +105,20 @@ var ExtractUserTeamsMeta = plugin.SubTaskMeta{ Description: "Extract Copilot user-team mappings into tool-layer table", Dependencies: []*plugin.SubTaskMeta{&CollectUserTeamsMeta}, } + +var CollectAiCreditUsageMeta = plugin.SubTaskMeta{ + Name: "collectAiCreditUsage", + EntryPoint: CollectAiCreditUsage, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect GitHub Copilot AI credit usage billing data from billing API", +} + +var ExtractAiCreditUsageMeta = plugin.SubTaskMeta{ + Name: "extractAiCreditUsage", + EntryPoint: ExtractAiCreditUsage, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract Copilot AI credit usage into tool-layer billing tables", + Dependencies: []*plugin.SubTaskMeta{&CollectAiCreditUsageMeta}, +} From 4e94637c01cbec84e87fe810f2a38da1878e0f0d Mon Sep 17 00:00:00 2001 From: AndreiS-gh Date: Thu, 16 Jul 2026 17:12:56 +0300 Subject: [PATCH 2/2] fix: resolve compiler errors and add user-level daily credit usage tracking --- .../models/enterprise_ai_credit_usage.go | 16 ++-- .../20260708_add_ai_credit_usage_metrics.go | 12 +-- .../gh-copilot/models/org_ai_credit_usage.go | 8 +- .../gh-copilot/models/user_ai_credit_usage.go | 6 +- .../plugins/gh-copilot/models/user_metrics.go | 1 + .../gh-copilot/tasks/ai_credit_collector.go | 87 +++++++++---------- .../gh-copilot/tasks/ai_credit_extractor.go | 45 +++++----- .../tasks/user_metrics_extractor.go | 2 + 8 files changed, 87 insertions(+), 90 deletions(-) mode change 100644 => 100755 backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go mode change 100644 => 100755 backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go mode change 100644 => 100755 backend/plugins/gh-copilot/models/org_ai_credit_usage.go mode change 100644 => 100755 backend/plugins/gh-copilot/models/user_ai_credit_usage.go mode change 100644 => 100755 backend/plugins/gh-copilot/models/user_metrics.go mode change 100644 => 100755 backend/plugins/gh-copilot/tasks/ai_credit_collector.go mode change 100644 => 100755 backend/plugins/gh-copilot/tasks/ai_credit_extractor.go mode change 100644 => 100755 backend/plugins/gh-copilot/tasks/user_metrics_extractor.go diff --git a/backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go b/backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go old mode 100644 new mode 100755 index 6bdcd5af7ce..8b01a8d4e12 --- a/backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go +++ b/backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go @@ -30,14 +30,14 @@ type GhCopilotEnterpriseAiCreditUsage struct { Month int `gorm:"primaryKey" json:"month"` Day int `gorm:"primaryKey" json:"day"` - Enterprise string `gorm:"primaryKey;type:varchar(255)" json:"enterprise" gorm:"comment:Enterprise slug"` - Model string `gorm:"primaryKey;type:varchar(255)" json:"model" gorm:"comment:AI model name (e.g., gpt-4.1)"` - Organization string `gorm:"index;type:varchar(255)" json:"organization" gorm:"comment:Organization within enterprise, if specified"` - User string `gorm:"index;type:varchar(255)" json:"user" gorm:"comment:Username, if specified"` - - Product string `gorm:"type:varchar(32)" json:"product" gorm:"comment:Product name (e.g., copilot)"` - CostCenterId string `gorm:"index;type:varchar(255)" json:"costCenterId" gorm:"comment:Cost center identifier"` - CostCenterName string `gorm:"type:varchar(255)" json:"costCenterName" gorm:"comment:Cost center display name"` + Enterprise string `gorm:"primaryKey;type:varchar(255);comment:Enterprise slug" json:"enterprise"` + Model string `gorm:"primaryKey;type:varchar(255);comment:AI model name (e.g., gpt-4.1)" json:"model"` + Organization string `gorm:"index;type:varchar(255);comment:Organization within enterprise, if specified" json:"organization"` + User string `gorm:"index;type:varchar(255);comment:Username, if specified" json:"user"` + + Product string `gorm:"type:varchar(32);comment:Product name (e.g., copilot)" json:"product"` + CostCenterId string `gorm:"index;type:varchar(255);comment:Cost center identifier" json:"costCenterId"` + CostCenterName string `gorm:"type:varchar(255);comment:Cost center display name" json:"costCenterName"` // Credit usage breakdown GrossQuantity float64 `json:"grossQuantity" gorm:"comment:Raw credits consumed"` diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go b/backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go old mode 100644 new mode 100755 index f935918fef2..6b52dd0940a --- a/backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go @@ -20,24 +20,24 @@ package migrationscripts import ( "github.com/apache/incubator-devlake/core/context" "github.com/apache/incubator-devlake/core/errors" - "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" "github.com/apache/incubator-devlake/helpers/migrationhelper" "github.com/apache/incubator-devlake/plugins/gh-copilot/models" ) type addAiCreditUsageMetrics struct{} -func (u *addAiCreditUsageMetrics) Up(baseCtx context.BasicContext) errors.Error { - return migrationhelper.AutoMigrate( - baseCtx, +func (u *addAiCreditUsageMetrics) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, &models.GhCopilotEnterpriseAiCreditUsage{}, &models.GhCopilotOrgAiCreditUsage{}, &models.GhCopilotUserAiCreditUsage{}, + &models.GhCopilotUserDailyMetrics{}, ) } -func (u *addAiCreditUsageMetrics) Version() string { - return "20260708_add_ai_credit_usage_metrics" +func (u *addAiCreditUsageMetrics) Version() uint64 { + return 20260708000000 } func (u *addAiCreditUsageMetrics) Name() string { diff --git a/backend/plugins/gh-copilot/models/org_ai_credit_usage.go b/backend/plugins/gh-copilot/models/org_ai_credit_usage.go old mode 100644 new mode 100755 index 3cbe515acf8..a5deb6bede4 --- a/backend/plugins/gh-copilot/models/org_ai_credit_usage.go +++ b/backend/plugins/gh-copilot/models/org_ai_credit_usage.go @@ -30,11 +30,11 @@ type GhCopilotOrgAiCreditUsage struct { Month int `gorm:"primaryKey" json:"month"` Day int `gorm:"primaryKey" json:"day"` - Organization string `gorm:"primaryKey;type:varchar(255)" json:"organization" gorm:"comment:Organization name"` - Model string `gorm:"primaryKey;type:varchar(255)" json:"model" gorm:"comment:AI model name (e.g., gpt-4.1)"` - User string `gorm:"index;type:varchar(255)" json:"user" gorm:"comment:Username consuming the credits"` + Organization string `gorm:"primaryKey;type:varchar(255);comment:Organization name" json:"organization"` + Model string `gorm:"primaryKey;type:varchar(255);comment:AI model name (e.g., gpt-4.1)" json:"model"` + User string `gorm:"index;type:varchar(255);comment:Username consuming the credits" json:"user"` - Product string `gorm:"type:varchar(32)" json:"product" gorm:"comment:Product name (e.g., copilot)"` + Product string `gorm:"type:varchar(32);comment:Product name (e.g., copilot)" json:"product"` // Credit usage breakdown GrossQuantity float64 `json:"grossQuantity" gorm:"comment:Raw credits consumed"` diff --git a/backend/plugins/gh-copilot/models/user_ai_credit_usage.go b/backend/plugins/gh-copilot/models/user_ai_credit_usage.go old mode 100644 new mode 100755 index 25ba54a0f40..a2066c2f36d --- a/backend/plugins/gh-copilot/models/user_ai_credit_usage.go +++ b/backend/plugins/gh-copilot/models/user_ai_credit_usage.go @@ -30,10 +30,10 @@ type GhCopilotUserAiCreditUsage struct { Month int `gorm:"primaryKey" json:"month"` Day int `gorm:"primaryKey" json:"day"` - User string `gorm:"primaryKey;type:varchar(255)" json:"user" gorm:"comment:GitHub username"` - Model string `gorm:"primaryKey;type:varchar(255)" json:"model" gorm:"comment:AI model name (e.g., gpt-4.1)"` + User string `gorm:"primaryKey;type:varchar(255);comment:GitHub username" json:"user"` + Model string `gorm:"primaryKey;type:varchar(255);comment:AI model name (e.g., gpt-4.1)" json:"model"` - Product string `gorm:"type:varchar(32)" json:"product" gorm:"comment:Product name (e.g., copilot)"` + Product string `gorm:"type:varchar(32);comment:Product name (e.g., copilot)" json:"product"` // Credit usage breakdown GrossQuantity float64 `json:"grossQuantity" gorm:"comment:Raw credits consumed"` diff --git a/backend/plugins/gh-copilot/models/user_metrics.go b/backend/plugins/gh-copilot/models/user_metrics.go old mode 100644 new mode 100755 index 18e9134c226..ad4ce83551d --- a/backend/plugins/gh-copilot/models/user_metrics.go +++ b/backend/plugins/gh-copilot/models/user_metrics.go @@ -38,6 +38,7 @@ type GhCopilotUserDailyMetrics struct { UsedCli bool `json:"usedCli" gorm:"comment:Whether user used Copilot CLI"` UsedCopilotCodeReviewActive bool `json:"usedCopilotCodeReviewActive" gorm:"comment:Whether user actively used code review"` UsedCopilotCodeReviewPassive bool `json:"usedCopilotCodeReviewPassive" gorm:"comment:Whether user passively used code review"` + AiCreditsUsed float64 `json:"aiCreditsUsed" gorm:"comment:AI credits consumed on this day"` CopilotActivityMetrics `mapstructure:",squash"` CopilotCliMetrics `mapstructure:",squash"` diff --git a/backend/plugins/gh-copilot/tasks/ai_credit_collector.go b/backend/plugins/gh-copilot/tasks/ai_credit_collector.go old mode 100644 new mode 100755 index 61e0d463201..f7831942289 --- a/backend/plugins/gh-copilot/tasks/ai_credit_collector.go +++ b/backend/plugins/gh-copilot/tasks/ai_credit_collector.go @@ -2,7 +2,7 @@ Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. -The ASF licenses this file to You under the Apache License, Version 2.0 +the ASF licenses this file to You under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at @@ -20,7 +20,6 @@ package tasks import ( "encoding/json" "fmt" - "io" "net/http" "net/url" "strconv" @@ -33,8 +32,6 @@ import ( const rawAiCreditUsageTable = "copilot_ai_credit_usage" -// CollectAiCreditUsage collects AI credit usage data from the billing API endpoints. -// Routes requests to enterprise, organization, or user endpoints based on connection configuration. func CollectAiCreditUsage(taskCtx plugin.SubTaskContext) errors.Error { data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData) if !ok { @@ -43,6 +40,11 @@ func CollectAiCreditUsage(taskCtx plugin.SubTaskContext) errors.Error { connection := data.Connection connection.Normalize() + apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection) + if err != nil { + return err + } + var urlTemplate string var scope string @@ -53,66 +55,55 @@ func CollectAiCreditUsage(taskCtx plugin.SubTaskContext) errors.Error { urlTemplate = fmt.Sprintf("organizations/%s/settings/billing/ai_credit/usage", connection.Organization) scope = connection.Organization } else { - // User-level credits are scoped to current authenticated user urlTemplate = "user/settings/billing/ai_credit/usage" scope = "user" } - // Build query parameters for date range now := time.Now().UTC() - params := url.Values{} - params.Set("year", strconv.Itoa(now.Year())) - params.Set("month", strconv.Itoa(int(now.Month()))) - params.Set("day", strconv.Itoa(now.Day())) - - apiCollector, err := helper.NewApiCollector(taskCtx, rawAiCreditUsageTable) - if err != nil { - return err - } - - err = apiCollector.CollectPages( - func(col *helper.ApiCollector) errors.Error { - col.GetAttr("scope", scope) - col.SetRelation(fmt.Sprintf("%s-%s", data.Connection.ID, scope)) - col.SetQuery("pageSize", "100") - for key, vals := range params { - col.SetQuery(key, vals[0]) - } - col.GetNextPageCustomerizedPath(fmt.Sprintf("%s?%s", urlTemplate, params.Encode()), nil) - return nil + queryParam := url.Values{} + queryParam.Set("year", strconv.Itoa(now.Year())) + queryParam.Set("month", strconv.Itoa(int(now.Month()))) + queryParam.Set("day", strconv.Itoa(now.Day())) + + collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: copilotRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: connection.Endpoint, + }, + Table: rawAiCreditUsageTable, }, - func(col *helper.ApiCollector, page int, res *http.Response) errors.Error { + ApiClient: apiClient, + UrlTemplate: urlTemplate, + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + return queryParam, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { if res.StatusCode == http.StatusNotFound { - taskCtx.GetLogger().Warnf("AI credit usage endpoint not found (404) for scope %s", scope) - return nil + taskCtx.GetLogger().Warn(nil, "AI credit usage endpoint not found (404) for scope %s", scope) + return nil, nil } if res.StatusCode != http.StatusOK { - return errors.HttpStatus(res.StatusCode).New(fmt.Sprintf("failed to collect AI credit usage for %s", scope)) + return nil, errors.HttpStatus(res.StatusCode).New(fmt.Sprintf("failed to collect AI credit usage for %s", scope)) } - body, err := io.ReadAll(res.Body) - if err != nil { - return errors.Convert(err) - } - defer res.Body.Close() - - // Parse response - endpoint returns usageItems array var response struct { - UsageItems []map[string]interface{} `json:"usageItems"` + UsageItems []json.RawMessage `json:"usageItems"` } - err = json.Unmarshal(body, &response) + err := helper.UnmarshalResponse(res, &response) if err != nil { - return errors.Convert(err) + return nil, err } - // Store raw response for extraction - for _, item := range response.UsageItems { - col.SaveRaw(item) - } - - return nil + return response.UsageItems, nil }, - ) + }) + if err != nil { + return err + } - return err + return collector.Execute() } diff --git a/backend/plugins/gh-copilot/tasks/ai_credit_extractor.go b/backend/plugins/gh-copilot/tasks/ai_credit_extractor.go old mode 100644 new mode 100755 index 024f72b7215..d777b1a682e --- a/backend/plugins/gh-copilot/tasks/ai_credit_extractor.go +++ b/backend/plugins/gh-copilot/tasks/ai_credit_extractor.go @@ -2,7 +2,7 @@ Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. -The ASF licenses this file to You under the Apache License, Version 2.0 +the ASF licenses this file to You under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at @@ -19,7 +19,6 @@ package tasks import ( "encoding/json" - "strconv" "time" "github.com/apache/incubator-devlake/core/errors" @@ -70,14 +69,18 @@ func ExtractAiCreditUsage(taskCtx plugin.SubTaskContext) errors.Error { connection := data.Connection connection.Normalize() - extractor, err := helper.NewApiExtractor(taskCtx, rawAiCreditUsageTable) - if err != nil { - return err - } - - err = extractor.Extract( - rawAiCreditUsageTable, - func(row *helper.RawData) ([]interface{}, errors.Error) { + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawAiCreditUsageTable, + Options: copilotRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: connection.Endpoint, + }, + }, + Extract: func(row *helper.RawData) ([]interface{}, errors.Error) { // Parse raw data var record aiCreditUsageRecord err := json.Unmarshal(row.Data, &record) @@ -87,18 +90,15 @@ func ExtractAiCreditUsage(taskCtx plugin.SubTaskContext) errors.Error { // Extract wrapper info from row context var wrapper aiCreditResponseWrapper - if scopeAttr, ok := row.Params["scope"]; ok { - if connection.HasEnterprise() { - wrapper.Enterprise = scopeAttr.(string) - } else if connection.Organization != "" { - wrapper.Organization = scopeAttr.(string) - } + if connection.HasEnterprise() { + wrapper.Enterprise = connection.Enterprise + } else if connection.Organization != "" { + wrapper.Organization = connection.Organization } wrapper.Product = record.Product wrapper.Model = record.Model - // Parse date from row (need to extract from response context) - // For now, use current date - this should be enhanced to parse from API response + // Parse date from row (use current time or parsed period) now := time.Now().UTC() wrapper.TimePeriod.Year = now.Year() wrapper.TimePeriod.Month = int(now.Month()) @@ -158,7 +158,7 @@ func ExtractAiCreditUsage(taskCtx plugin.SubTaskContext) errors.Error { Year: wrapper.TimePeriod.Year, Month: wrapper.TimePeriod.Month, Day: wrapper.TimePeriod.Day, - User: connection.GetEmail(), // Use authenticated user + User: connection.Name, // Use connection display name Model: record.Model, Product: record.Product, GrossQuantity: record.GrossQuantity, @@ -174,7 +174,10 @@ func ExtractAiCreditUsage(taskCtx plugin.SubTaskContext) errors.Error { return results, nil }, - ) + }) + if err != nil { + return err + } - return err + return extractor.Execute() } diff --git a/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go old mode 100644 new mode 100755 index 72992194063..7671fab86a9 --- a/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go +++ b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go @@ -49,6 +49,7 @@ type userDailyReport struct { UsedCli bool `json:"used_cli"` UsedCopilotCodeReviewActive bool `json:"used_copilot_code_review_active"` UsedCopilotCodeReviewPassive bool `json:"used_copilot_code_review_passive"` + AiCreditsUsed float64 `json:"ai_credits_used"` TotalsByIde []userTotalsByIde `json:"totals_by_ide"` TotalsByFeature []totalsByFeature `json:"totals_by_feature"` TotalsByLanguageFeature []totalsByLangFeature `json:"totals_by_language_feature"` @@ -123,6 +124,7 @@ func ExtractUserMetrics(taskCtx plugin.SubTaskContext) errors.Error { UsedCli: u.UsedCli, UsedCopilotCodeReviewActive: u.UsedCopilotCodeReviewActive, UsedCopilotCodeReviewPassive: u.UsedCopilotCodeReviewPassive, + AiCreditsUsed: u.AiCreditsUsed, CopilotActivityMetrics: models.CopilotActivityMetrics{ UserInitiatedInteractionCount: u.UserInitiatedInteractionCount, CodeGenerationActivityCount: u.CodeGenerationActivityCount,