package artifact import ( "context" "encoding/json" "fmt" "strings" "time" ) // imageUsage mirrors the usage object returned by GPT Image endpoints. Keeping // it typed avoids losing precision when provider metadata is persisted. type imageUsage struct { TotalTokens int64 `json:"total_tokens"` InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` TextInputTokens int64 `json:"text_input_tokens"` ImageInputTokens int64 `json:"image_input_tokens"` } func (u imageUsage) available() bool { return u.TotalTokens > 0 || u.InputTokens > 0 || u.OutputTokens > 0 || u.TextInputTokens > 0 || u.ImageInputTokens > 0 } type imagePricing struct { TextInputPerMTok float64 ImageInputPerMTok float64 ImageOutputPerMTok float64 Basis string } // openAIImagePricing intentionally supports only models whose public standard // token rates are pinned here. Unknown/future model names still get full usage // logging, but their local cost remains NULL instead of inventing a price. func openAIImagePricing(model string) (imagePricing, bool) { m := strings.ToLower(strings.TrimSpace(model)) switch { case m == "gpt-image-2" || strings.HasPrefix(m, "gpt-image-2-"): return imagePricing{ TextInputPerMTok: 5.00, ImageInputPerMTok: 8.00, ImageOutputPerMTok: 30.00, Basis: "openai-standard-2026-08-10:gpt-image-2:text-in-5,image-in-8,image-out-30-usd-per-mtok", }, true case m == "gpt-image-1.5" || strings.HasPrefix(m, "gpt-image-1.5-"): return imagePricing{ TextInputPerMTok: 5.00, ImageInputPerMTok: 8.00, ImageOutputPerMTok: 32.00, Basis: "openai-standard-2026-08-10:gpt-image-1.5:text-in-5,image-in-8,image-out-32-usd-per-mtok", }, true case m == "chatgpt-image-latest": return imagePricing{ TextInputPerMTok: 5.00, ImageInputPerMTok: 8.00, ImageOutputPerMTok: 32.00, Basis: "openai-standard-2026-08-10:chatgpt-image-latest:text-in-5,image-in-8,image-out-32-usd-per-mtok", }, true case m == "gpt-image-1": return imagePricing{ TextInputPerMTok: 5.00, ImageInputPerMTok: 10.00, ImageOutputPerMTok: 40.00, Basis: "openai-standard-2026-08-10:gpt-image-1:text-in-5,image-in-10,image-out-40-usd-per-mtok", }, true case m == "gpt-image-1-mini": return imagePricing{ TextInputPerMTok: 2.00, ImageInputPerMTok: 2.50, ImageOutputPerMTok: 8.00, Basis: "openai-standard-2026-08-10:gpt-image-1-mini:text-in-2,image-in-2.5,image-out-8-usd-per-mtok", }, true default: return imagePricing{}, false } } func estimateOpenAIImageCost(model string, u imageUsage) (float64, string, bool) { p, ok := openAIImagePricing(model) if !ok || !u.available() { return 0, "", false } // GPT Image reports the text/image input split. It matters because the two // modalities have different rates. If the split is missing, store usage but // do not claim a precise local cost. if u.InputTokens > 0 && u.TextInputTokens+u.ImageInputTokens <= 0 { return 0, p.Basis, false } if split := u.TextInputTokens + u.ImageInputTokens; u.InputTokens > 0 && split > 0 && split != u.InputTokens { return 0, p.Basis, false } cost := (float64(u.TextInputTokens)*p.TextInputPerMTok + float64(u.ImageInputTokens)*p.ImageInputPerMTok + float64(u.OutputTokens)*p.ImageOutputPerMTok) / 1_000_000 return cost, p.Basis, true } func (w *Worker) recordOpenAIUsage(ctx context.Context, taskID, kind string, res imageResult) error { // Log every successful OpenAI image call, even if a provider response omits // the optional usage block. That keeps the call/card counters truthful while // leaving the local cost NULL when there is not enough data to calculate it. u := imageUsage{} if res.Usage != nil { u = *res.Usage } model := metaString(res.Meta, "model") endpoint := metaString(res.Meta, "endpoint") size := metaString(res.Meta, "size") quality := metaString(res.Meta, "quality") requestID := metaString(res.Meta, "request_id") var cost any if res.EstimatedCostUSD != nil { cost = *res.EstimatedCostUSD } metaJSON, _ := json.Marshal(res.Meta) _, err := w.db.ExecContext(ctx, `INSERT INTO artifact_api_usage( created_at,task_id,kind,provider,model,endpoint,size,quality,request_id, input_tokens,input_text_tokens,input_image_tokens,output_tokens,total_tokens, estimated_cost_usd,pricing_basis,meta_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, time.Now().UTC().UnixMilli(), nullableString(taskID), kind, res.Provider, model, endpoint, size, quality, nullableString(requestID), u.InputTokens, u.TextInputTokens, u.ImageInputTokens, u.OutputTokens, u.TotalTokens, cost, res.PricingBasis, string(metaJSON)) return err } func nullableString(s string) any { if strings.TrimSpace(s) == "" { return nil } return s } func metaString(meta map[string]any, key string) string { if meta == nil { return "" } v, ok := meta[key] if !ok || v == nil { return "" } if s, ok := v.(string); ok { return s } return fmt.Sprint(v) }