completion-kit 0.5.5 → 0.5.7

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89a2dd49e9edb75dada51386769cfd7e16f596c3b30c434588864487ab801d5b
4
- data.tar.gz: df8d9acfd4ef8aeab73508e7cf9adb2c083c7b9ef3d1c4304e1a3e01723b17e1
3
+ metadata.gz: 33daa5f930979539f0b757bd5ea85273b9620f90f3de0bf4d976c843cae24af5
4
+ data.tar.gz: 3387de72acf76fbeac7d79628144ac9cb3782678cf186ad883d1978bceb4daee
5
5
  SHA512:
6
- metadata.gz: 9b03af38565cb43b7a57747896136817d954b06a89ba4551afb314ca93cd56a70e9d3068271d4f522741efa7d830b106a3a39e05997ea38fc3fb65996741ad08
7
- data.tar.gz: be0dad1d98c850854770351edbfdcd7867fbda7b6590634bae2997707a136a94cee066c79fd2ae8a259ea9c01db06a89b7088012a072655c7c627f7c475a801a
6
+ metadata.gz: 3af6ed91c4c791859b41cddf24078fc0ecec5a79a5aa6ead1ff996c07f9173164693bea12ac223443ae33fcadb1499919734c112a453260f4e9e187cbd6cfd37
7
+ data.tar.gz: cf05c70a638f3ba9c70657b9c4ab90154af4b5d15e44dbff80b00bd2fc5804e2f3c6313f22fba5283ab36d35adb6d218ec3e1792ee533191c1a7bb66eef59de6
data/README.md CHANGED
@@ -142,7 +142,7 @@ Only one mode can be active.
142
142
 
143
143
  ## Concepts
144
144
 
145
- - **Prompt.** A versioned template with `{{variable}}` placeholders. Publishing freezes the template; editing a published prompt creates a new version.
145
+ - **Prompt.** A versioned template with `{{variable}}` placeholders. Editing a prompt that's already been run creates a new version, so earlier results stay reproducible.
146
146
  - **Dataset.** A CSV of real inputs. Each row becomes one test case.
147
147
  - **Run.** One execution of a prompt against a dataset. Captures every input (model, temperature, metrics) and stores all outputs and scores.
148
148
  - **Response.** The model's output for one dataset row, with reviews attached.
@@ -105,11 +105,16 @@ document.addEventListener("mouseout", function(e) {
105
105
  });
106
106
 
107
107
  var ckRefreshing = false;
108
+ function ckSetRefreshButtonsBusy(busy) {
109
+ document.querySelectorAll('.ck-icon-btn[title="Refresh models"]').forEach(function(btn) {
110
+ btn.classList.toggle('ck-icon-btn--spinning', busy);
111
+ btn.disabled = busy;
112
+ });
113
+ }
108
114
  function ckRefreshModels() {
109
115
  if (ckRefreshing) return;
110
116
  ckRefreshing = true;
111
- var btn = document.querySelector('.ck-icon-btn[title="Refresh models"]');
112
- if (btn) btn.classList.add('ck-icon-btn--spinning');
117
+ ckSetRefreshButtonsBusy(true);
113
118
  ckUpdateRefreshProgress();
114
119
  var csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
115
120
  fetch("/completion_kit/refresh_models", {
@@ -149,8 +154,7 @@ document.addEventListener("turbo:before-stream-render", function(event) {
149
154
  }
150
155
  if (target === "prompt_llm_model" || target === "run_judge_model") {
151
156
  ckRefreshing = false;
152
- var btn = document.querySelector('.ck-icon-btn[title="Refresh models"]');
153
- if (btn) btn.classList.remove('ck-icon-btn--spinning');
157
+ ckSetRefreshButtonsBusy(false);
154
158
  var status = document.getElementById('refresh-status');
155
159
  if (status) { status.textContent = 'Models updated.'; setTimeout(function() { status.textContent = ' '; }, 3000); }
156
160
  }
@@ -1164,6 +1164,17 @@ tr:hover .ck-chip--publish {
1164
1164
  cursor: help;
1165
1165
  }
1166
1166
 
1167
+ .ck-model-table tr.ck-model-table__section td {
1168
+ padding: 0.85rem 0 0.3rem;
1169
+ font-family: var(--ck-mono);
1170
+ font-size: 0.66rem;
1171
+ font-weight: 600;
1172
+ letter-spacing: 0.16em;
1173
+ text-transform: uppercase;
1174
+ color: var(--ck-dim);
1175
+ }
1176
+ .ck-model-table tr.ck-model-table__section:first-child td { padding-top: 0; }
1177
+
1167
1178
  .ck-model-list__summary {
1168
1179
  display: flex;
1169
1180
  align-items: center;
@@ -1278,6 +1289,102 @@ tr:hover .ck-chip--publish {
1278
1289
  max-width: 36rem;
1279
1290
  }
1280
1291
 
1292
+ .ck-version-note {
1293
+ margin: 0 0 1rem;
1294
+ max-width: 36rem;
1295
+ font-family: var(--ck-sans);
1296
+ font-size: 0.85rem;
1297
+ line-height: 1.5;
1298
+ color: var(--ck-muted);
1299
+ }
1300
+
1301
+ .ck-version-change {
1302
+ margin: 0.5rem 0 0;
1303
+ display: flex;
1304
+ align-items: center;
1305
+ gap: 0.5rem;
1306
+ flex-wrap: wrap;
1307
+ font-family: var(--ck-mono);
1308
+ font-size: 0.8rem;
1309
+ }
1310
+ .ck-version-change__label {
1311
+ font-size: 0.66rem;
1312
+ font-weight: 600;
1313
+ letter-spacing: 0.12em;
1314
+ text-transform: uppercase;
1315
+ color: var(--ck-dim);
1316
+ }
1317
+ .ck-version-change__old {
1318
+ padding: 0.15em 0.5em;
1319
+ border-radius: var(--ck-radius);
1320
+ background: var(--ck-danger-soft);
1321
+ color: var(--ck-danger);
1322
+ text-decoration: line-through;
1323
+ }
1324
+ .ck-version-change__arrow { color: var(--ck-dim); }
1325
+ .ck-version-change__new {
1326
+ padding: 0.15em 0.5em;
1327
+ border-radius: var(--ck-radius);
1328
+ background: var(--ck-success-soft);
1329
+ color: var(--ck-success);
1330
+ }
1331
+
1332
+ .ck-modal__body .ck-version-change + .ck-suggest-diff { margin-top: 0.9rem; }
1333
+
1334
+ .ck-cell-link {
1335
+ background: none;
1336
+ border: none;
1337
+ padding: 0;
1338
+ font-family: var(--ck-mono);
1339
+ font-size: 0.8rem;
1340
+ color: var(--ck-accent);
1341
+ cursor: pointer;
1342
+ text-decoration: underline;
1343
+ text-underline-offset: 2px;
1344
+ }
1345
+ .ck-cell-link:hover { color: var(--ck-accent-hover); }
1346
+ .ck-cell-link--delta {
1347
+ font-size: 1rem;
1348
+ font-weight: 700;
1349
+ text-decoration: none;
1350
+ color: var(--ck-dim);
1351
+ }
1352
+ .ck-cell-link--delta:hover { color: var(--ck-accent); }
1353
+
1354
+ /* Version-history table: cell that pairs the version label + publish control (left) with the diff trigger (right) */
1355
+ .ck-version-cell {
1356
+ display: flex;
1357
+ align-items: center;
1358
+ justify-content: space-between;
1359
+ gap: 0.75rem;
1360
+ }
1361
+ .ck-version-cell__label { display: flex; align-items: center; gap: 0.5rem; }
1362
+
1363
+ /* the version currently being viewed: faint accent wash (like the model chips)
1364
+ plus a bright accent border drawn on the cells (Safari can't box-shadow a <tr>) */
1365
+ .ck-results-table tbody tr.ck-results-table__row--active,
1366
+ .ck-results-table tbody tr.ck-results-table__row--active:hover {
1367
+ background: var(--ck-accent-soft);
1368
+ }
1369
+ .ck-results-table tbody tr.ck-results-table__row--active td {
1370
+ box-shadow: inset 0 2px 0 var(--ck-accent), inset 0 -2px 0 var(--ck-accent);
1371
+ }
1372
+ .ck-results-table tbody tr.ck-results-table__row--active td:first-child {
1373
+ box-shadow: inset 0 2px 0 var(--ck-accent), inset 0 -2px 0 var(--ck-accent), inset 2px 0 0 var(--ck-accent);
1374
+ }
1375
+ .ck-results-table tbody tr.ck-results-table__row--active td:last-child {
1376
+ box-shadow: inset 0 2px 0 var(--ck-accent), inset 0 -2px 0 var(--ck-accent), inset -2px 0 0 var(--ck-accent);
1377
+ }
1378
+ /* when it's the last row, round its bottom corners so the border isn't clipped
1379
+ by the table's rounded container */
1380
+ .ck-results-table tbody tr.ck-results-table__row--active:last-child td:first-child {
1381
+ border-bottom-left-radius: calc(var(--ck-radius-lg) - 1px);
1382
+ }
1383
+ .ck-results-table tbody tr.ck-results-table__row--active:last-child td:last-child {
1384
+ border-bottom-right-radius: calc(var(--ck-radius-lg) - 1px);
1385
+ }
1386
+ .ck-results-table tbody tr.ck-results-table__row--active strong { color: var(--ck-accent-hover); }
1387
+
1281
1388
  .ck-field-hint {
1282
1389
  font-family: var(--ck-sans);
1283
1390
  font-size: 0.8rem;
@@ -1659,30 +1766,54 @@ tr:hover .ck-chip--publish {
1659
1766
  color: var(--ck-text);
1660
1767
  }
1661
1768
 
1769
+ @keyframes ck-flash-in {
1770
+ from { opacity: 0; transform: translateY(-4px); }
1771
+ to { opacity: 1; transform: translateY(0); }
1772
+ }
1773
+
1662
1774
  .ck-flash {
1663
- margin-bottom: 1rem;
1664
- padding: 0.75rem 1rem;
1665
- border-radius: var(--ck-radius);
1775
+ margin-bottom: 1.25rem;
1776
+ padding: 0.8rem 1rem;
1666
1777
  border: 1px solid transparent;
1778
+ border-radius: var(--ck-radius);
1667
1779
  font-family: var(--ck-sans);
1668
- font-size: 0.95rem;
1780
+ font-size: 0.9rem;
1781
+ line-height: 1.6;
1782
+ animation: ck-flash-in 0.22s ease both;
1669
1783
  }
1784
+ .ck-flash--notice { background: rgba(45, 212, 168, 0.08); border-color: rgba(45, 212, 168, 0.32); }
1785
+ .ck-flash--alert { background: rgba(248, 113, 113, 0.08); border-color: rgba(248, 113, 113, 0.32); }
1670
1786
 
1671
- .ck-flash--notice {
1672
- border-color: rgba(34, 197, 94, 0.3);
1673
- background: var(--ck-success-soft);
1674
- color: var(--ck-success);
1787
+ /* layout flash: a mono status tag (the engine's kicker treatment) inline before the message */
1788
+ .ck-flash__label {
1789
+ margin-right: 0.6rem;
1790
+ font-family: var(--ck-mono);
1791
+ font-size: 0.7rem;
1792
+ font-weight: 600;
1793
+ letter-spacing: 0.14em;
1794
+ text-transform: uppercase;
1795
+ white-space: nowrap;
1675
1796
  }
1676
-
1677
- .ck-flash--alert {
1678
- border-color: rgba(239, 68, 68, 0.3);
1679
- background: var(--ck-danger-soft);
1680
- color: var(--ck-danger);
1797
+ .ck-flash__label::before {
1798
+ content: "";
1799
+ display: inline-block;
1800
+ width: 6px;
1801
+ height: 6px;
1802
+ margin-right: 0.45rem;
1803
+ border-radius: 50%;
1804
+ background: currentColor;
1805
+ box-shadow: 0 0 7px currentColor;
1806
+ vertical-align: 0.12em;
1681
1807
  }
1808
+ .ck-flash--notice .ck-flash__label { color: var(--ck-success); }
1809
+ .ck-flash--alert .ck-flash__label { color: var(--ck-danger); }
1810
+ .ck-flash__body { color: var(--ck-text); }
1682
1811
 
1683
- .ck-flash__title {
1684
- margin: 0;
1685
- font-weight: 700;
1812
+ /* form-error summary inside .ck-flash--alert: title sentence + ck-error-list */
1813
+ .ck-flash__title { margin: 0; font-weight: 600; color: var(--ck-danger); }
1814
+
1815
+ @media (prefers-reduced-motion: reduce) {
1816
+ .ck-flash { animation: none; }
1686
1817
  }
1687
1818
 
1688
1819
  .ck-banner {
@@ -2012,6 +2143,10 @@ select.ck-input {
2012
2143
  box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55);
2013
2144
  overflow: hidden;
2014
2145
  }
2146
+ /* the panel is the dialog's autofocus target (so the close button isn't auto-focused);
2147
+ it's a container, not a control, so don't show a ring on it */
2148
+ .ck-modal__panel:focus,
2149
+ .ck-modal__panel:focus-visible { outline: none; }
2015
2150
 
2016
2151
  .ck-modal__header {
2017
2152
  display: flex;
@@ -2048,9 +2183,8 @@ select.ck-input {
2048
2183
 
2049
2184
  .ck-modal__close {
2050
2185
  appearance: none;
2186
+ position: relative;
2051
2187
  display: inline-flex;
2052
- align-items: center;
2053
- justify-content: center;
2054
2188
  width: 28px;
2055
2189
  height: 28px;
2056
2190
  padding: 0;
@@ -2059,12 +2193,25 @@ select.ck-input {
2059
2193
  outline: none;
2060
2194
  background: transparent;
2061
2195
  color: var(--ck-dim);
2062
- font-family: var(--ck-sans);
2063
- font-size: 1.5rem;
2064
- line-height: 1;
2196
+ font-size: 0;
2065
2197
  cursor: pointer;
2066
2198
  transition: color 0.15s, background 0.15s;
2067
2199
  }
2200
+ /* the "×" is drawn as two crossed bars centred on the button, so it (and the
2201
+ focus ring around the button) sit dead centre regardless of font metrics */
2202
+ .ck-modal__close::before,
2203
+ .ck-modal__close::after {
2204
+ content: "";
2205
+ position: absolute;
2206
+ top: 50%;
2207
+ left: 50%;
2208
+ width: 13px;
2209
+ height: 1.6px;
2210
+ border-radius: 1px;
2211
+ background: currentColor;
2212
+ }
2213
+ .ck-modal__close::before { transform: translate(-50%, -50%) rotate(45deg); }
2214
+ .ck-modal__close::after { transform: translate(-50%, -50%) rotate(-45deg); }
2068
2215
 
2069
2216
  .ck-modal__close:hover {
2070
2217
  color: var(--ck-text);
@@ -2083,6 +2230,8 @@ select.ck-input {
2083
2230
  overflow: auto;
2084
2231
  padding: 0 1.5rem;
2085
2232
  }
2233
+ /* no footer? then the body needs its own bottom padding */
2234
+ .ck-modal__body:last-child { padding-bottom: 1.5rem; }
2086
2235
 
2087
2236
  .ck-modal__footer {
2088
2237
  display: flex;
@@ -2603,9 +2752,15 @@ select.ck-input {
2603
2752
  }
2604
2753
  }
2605
2754
 
2755
+ /* the metrics field stacks several sub-sections (hint, groups, divider, tag
2756
+ filter, checkboxes) — give it more vertical breathing room than a plain field */
2757
+ #metrics-field {
2758
+ gap: 0.85rem;
2759
+ }
2760
+
2606
2761
  .ck-metric-groups {
2607
2762
  display: grid;
2608
- gap: 0.4rem;
2763
+ gap: 0.55rem;
2609
2764
  margin-bottom: 0.5rem;
2610
2765
  }
2611
2766
 
@@ -2621,7 +2776,7 @@ select.ck-input {
2621
2776
  .ck-metric-groups__row {
2622
2777
  display: flex;
2623
2778
  flex-wrap: wrap;
2624
- gap: 0.4rem;
2779
+ gap: 0.5rem;
2625
2780
  }
2626
2781
 
2627
2782
  .ck-metric-group-pill {
@@ -2878,6 +3033,29 @@ select.ck-input {
2878
3033
  color: var(--ck-dim);
2879
3034
  }
2880
3035
 
3036
+ .ck-prompts-table__desc {
3037
+ margin: 0.3rem 0 0;
3038
+ font-family: var(--ck-sans);
3039
+ font-size: 0.82rem;
3040
+ font-weight: 400;
3041
+ line-height: 1.45;
3042
+ color: var(--ck-muted);
3043
+ white-space: normal;
3044
+ max-width: 42ch;
3045
+ }
3046
+
3047
+ .ck-endpoint--compact {
3048
+ margin-top: 0.45rem;
3049
+ max-width: 26rem;
3050
+ }
3051
+ .ck-endpoint--compact .ck-endpoint__url {
3052
+ min-width: 0;
3053
+ white-space: nowrap;
3054
+ overflow: hidden;
3055
+ text-overflow: ellipsis;
3056
+ }
3057
+ .ck-endpoint--compact .ck-icon-btn { flex-shrink: 0; }
3058
+
2881
3059
  .ck-clamp-2 {
2882
3060
  display: -webkit-box;
2883
3061
  -webkit-line-clamp: 2;
@@ -50,7 +50,7 @@ module CompletionKit
50
50
 
51
51
  def publish
52
52
  @prompt.publish!
53
- redirect_to prompt_path(@prompt), notice: "#{@prompt.display_name} is now the current version."
53
+ redirect_to prompt_path(@prompt), notice: "#{@prompt.display_name} is now the published version."
54
54
  end
55
55
 
56
56
  private
@@ -28,6 +28,11 @@ module CompletionKit
28
28
 
29
29
  def new
30
30
  @run = Run.new(prompt_id: params[:prompt_id])
31
+ prompt = Prompt.find_by(id: @run.prompt_id)
32
+ if prompt
33
+ last_run = Run.where(prompt_id: prompt.family_versions.ids).order(created_at: :desc).first
34
+ @run.tag_names = last_run.tag_names if last_run
35
+ end
31
36
  end
32
37
 
33
38
  def edit
@@ -79,6 +84,7 @@ module CompletionKit
79
84
  dataset_id: @run.dataset_id,
80
85
  judge_model: @run.judge_model,
81
86
  temperature: @run.temperature,
87
+ tag_names: @run.tag_names,
82
88
  status: "pending"
83
89
  )
84
90
  new_run.replace_metrics!(@run.metric_ids)
@@ -72,6 +72,47 @@ module CompletionKit
72
72
  CompletionKit::ProviderCredential::PROVIDER_LABELS[provider.to_s] || provider.to_s.titleize
73
73
  end
74
74
 
75
+ def ck_masked_token(token)
76
+ return "YOUR_TOKEN" if token.blank?
77
+ return "••••••••" if token.length < 12
78
+ "#{token[0..3]}#{'•' * [token.length - 8, 4].max}#{token[-4..]}"
79
+ end
80
+
81
+ OPENAI_MODEL_FAMILY_ORDER = ["GPT-5", "GPT-4", "o-series", "GPT-3.5", "GPT-OSS", "Other"].freeze
82
+
83
+ def ck_openai_model_family(model_id)
84
+ id = model_id.to_s
85
+ return "GPT-5" if id.match?(/\Agpt-5/i)
86
+ return "GPT-4" if id.match?(/\Agpt-4/i)
87
+ return "GPT-3.5" if id.match?(/\Agpt-3/i)
88
+ return "GPT-OSS" if id.match?(/\Agpt-oss/i)
89
+ return "o-series" if id.match?(/\Ao\d/i)
90
+ "Other"
91
+ end
92
+
93
+ # Groups a provider's models for the models-card table, mirroring how the
94
+ # dropdown sub-groups: OpenRouter clusters by upstream vendor (the part
95
+ # before "/"); OpenAI clusters by family; everyone else stays flat. Returns
96
+ # [[section_label_or_nil, [models]], ...]. A single section collapses to a
97
+ # nil label so we don't render a redundant header.
98
+ def ck_model_table_sections(models)
99
+ models = models.to_a
100
+ sections =
101
+ case models.first&.provider
102
+ when "openrouter"
103
+ models.group_by { |m| m.model_id.to_s.split("/", 2).first.delete_prefix("~") }
104
+ .sort_by { |label, _| label }
105
+ when "openai"
106
+ grouped = models.group_by { |m| ck_openai_model_family(m.model_id) }
107
+ ordered = OPENAI_MODEL_FAMILY_ORDER.filter_map { |label| [label, grouped[label]] if grouped[label] }
108
+ extras = (grouped.keys - OPENAI_MODEL_FAMILY_ORDER).sort.map { |label| [label, grouped[label]] }
109
+ ordered + extras
110
+ else
111
+ [[nil, models]]
112
+ end
113
+ sections.size <= 1 ? [[nil, models]] : sections
114
+ end
115
+
75
116
  def ck_model_option_label(model)
76
117
  return "#{model[:name]} (?)" if model.key?(:judging_confirmed) && !model[:judging_confirmed]
77
118
  model[:name]
@@ -85,20 +126,33 @@ module CompletionKit
85
126
  end
86
127
  end
87
128
 
88
- groups = models.group_by do |m|
89
- if m[:provider] == "openrouter"
90
- upstream = m[:id].to_s.split("/", 2).first
91
- "OpenRouter — #{upstream}"
92
- else
93
- ck_provider_label(m[:provider])
94
- end
95
- end
96
-
97
- ordered_keys = groups.keys.sort_by { |label| [label.start_with?("OpenRouter") ? 1 : 0, label] }
129
+ groups = models.group_by { |m| ck_model_optgroup_label(m) }
130
+ ordered_keys = groups.keys.sort_by { |label| ck_model_optgroup_sort_key(label) }
98
131
  grouped = ordered_keys.map { |label| [label, groups[label].map { |m| [ck_model_option_label(m), m[:id]] }] }
99
132
  grouped_options_for_select(grouped, selected)
100
133
  end
101
134
 
135
+ # Optgroup label for the model select — mirrors the provider models table:
136
+ # OpenRouter splits by upstream vendor, OpenAI splits by family, everyone
137
+ # else is a single group.
138
+ def ck_model_optgroup_label(model)
139
+ case model[:provider]
140
+ when "openrouter" then "OpenRouter — #{model[:id].to_s.split("/", 2).first.delete_prefix("~")}"
141
+ when "openai" then "OpenAI — #{ck_openai_model_family(model[:id])}"
142
+ else ck_provider_label(model[:provider])
143
+ end
144
+ end
145
+
146
+ def ck_model_optgroup_sort_key(label)
147
+ if label.start_with?("OpenAI — ")
148
+ [0, OPENAI_MODEL_FAMILY_ORDER.index(label.delete_prefix("OpenAI — ")), label]
149
+ elsif label.start_with?("OpenRouter")
150
+ [2, 0, label]
151
+ else
152
+ [1, 0, label]
153
+ end
154
+ end
155
+
102
156
  def ck_model_options_html(scope)
103
157
  models = CompletionKit::ApiConfig.available_models(scope: scope)
104
158
  return "" if models.empty?
@@ -47,6 +47,14 @@ module CompletionKit
47
47
  false
48
48
  end
49
49
 
50
+ def model_count
51
+ Model.where(provider: provider).active.count
52
+ end
53
+
54
+ def self.discovery_in_progress?
55
+ where(discovery_status: "discovering").exists?
56
+ end
57
+
50
58
  def prompt_count
51
59
  model_ids = Model.where(provider: provider).pluck(:model_id)
52
60
  return 0 if model_ids.empty?
@@ -1,6 +1,9 @@
1
1
  module CompletionKit
2
2
  class WorkerHealth
3
- HEARTBEAT_THRESHOLD = 30.seconds
3
+ # SolidQueue workers heartbeat every SolidQueue.process_heartbeat_interval
4
+ # (60s by default), so a 30s window flagged healthy workers as down between
5
+ # beats. Allow two missed heartbeats before we say the worker is gone.
6
+ HEARTBEAT_THRESHOLD = 2.minutes
4
7
 
5
8
  def self.healthy?
6
9
  return true unless defined?(::SolidQueue::Process)
@@ -0,0 +1,11 @@
1
+ <% if token.present? %>
2
+ <% masked = ck_masked_token(token) %>
3
+ <div style="display: flex; align-items: center; gap: 0.4rem; margin: 0.5rem 0;">
4
+ <button type="button" class="ck-chip ck-token-toggle" onclick="ckToggleToken(this)" data-masked="<%= masked %>" data-real="<%= token %>" aria-label="Reveal API token" aria-pressed="false" style="cursor: pointer;"><%= masked %></button>
5
+ <button type="button" class="ck-icon-btn" title="Copy token" aria-label="Copy API token" onclick="var b=this;navigator.clipboard.writeText('<%= token %>').then(function(){b.classList.add('ck-api-copy--done');setTimeout(function(){b.classList.remove('ck-api-copy--done')},1500)})">
6
+ <%= heroicon_tag "clipboard-document", variant: :outline, size: 14, "aria-hidden": "true" %>
7
+ </button>
8
+ </div>
9
+ <% else %>
10
+ <p class="ck-meta-copy">No API token configured. Set <code>COMPLETION_KIT_API_TOKEN</code> to enable API access.</p>
11
+ <% end %>